From 44d9a5f83dcba49425b2a2a1f55bc4286a75e28f Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 19 May 2026 17:11:33 +0300 Subject: [PATCH 01/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Skip=20API-k?= =?UTF-8?q?ey=20users=20when=20expanding=20notification=20recipients"=20(#?= =?UTF-8?q?74408)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip API-key users when expanding notification recipients (#73922) Co-authored-by: Ngoc Khuat --- src/metabase/channel/impl/email.clj | 7 +++++-- .../permissions/models/permissions_group.clj | 1 + test/metabase/channel/impl/email_test.clj | 12 ++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/metabase/channel/impl/email.clj b/src/metabase/channel/impl/email.clj index b6fc884cc175..d428dd268802 100644 --- a/src/metabase/channel/impl/email.clj +++ b/src/metabase/channel/impl/email.clj @@ -315,9 +315,12 @@ :let [details (:details recipient) emails (case (:type recipient) :notification-recipient/user - [(-> recipient :user :email)] + (when (not= :api-key (-> recipient :user :type)) + [(-> recipient :user :email)]) :notification-recipient/group - (->> recipient :permissions_group :members (map :email)) + (->> recipient :permissions_group :members + (remove #(= :api-key (:type %))) + (map :email)) :notification-recipient/raw-value [(:value details)] :notification-recipient/template diff --git a/src/metabase/permissions/models/permissions_group.clj b/src/metabase/permissions/models/permissions_group.clj index e0bd730e1a29..11f5d9e42b9a 100644 --- a/src/metabase/permissions/models/permissions_group.clj +++ b/src/metabase/permissions/models/permissions_group.clj @@ -165,6 +165,7 @@ :u.last_name :u.email :u.is_superuser + :u.type :pgm.group_id [:pgm.id :membership_id] (when (premium-features/enable-advanced-permissions?) diff --git a/test/metabase/channel/impl/email_test.clj b/test/metabase/channel/impl/email_test.clj index a9f7c057242d..8627900eda73 100644 --- a/test/metabase/channel/impl/email_test.clj +++ b/test/metabase/channel/impl/email_test.clj @@ -118,6 +118,18 @@ {:template-type :email/handlebars-resource :channel-type :channel/email}))))))) +(deftest notification-recipients-skips-api-key-users-test + (testing "API-key users are filtered out of notification recipients (GDGT-2402)" + (let [recipients [{:type :notification-recipient/group + :permissions_group {:members [{:email "alice@metabase.com" :type :personal} + {:email "api-key-user-abc@api-key.invalid" :type :api-key}]}} + {:type :notification-recipient/user + :user {:email "api-key-user-def@api-key.invalid" :type :api-key}} + {:type :notification-recipient/raw-value + :details {:value "ops@metabase.com"}}]] + (is (= ["alice@metabase.com" "ops@metabase.com"] + (#'email.impl/notification-recipients->emails recipients {})))))) + (deftest render-body-logging-test (testing "rendering a user-provided template logs the template body at debug level" (mt/with-log-messages-for-level [messages :debug] From ab3aae9c366d02e18f0eaace7995f293f8fa3ba1 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 19 May 2026 17:49:44 +0300 Subject: [PATCH 02/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"docs:=20usag?= =?UTF-8?q?e=20analytics=20updates"=20(#74424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs: usage analytics updates (#74386) * usage analytics updates * Apply suggestions from code review --------- Co-authored-by: Alex Yarosh Co-authored-by: Jeff Bruemmer --- docs/configuring-metabase/settings.md | 13 +++++++++++-- docs/embedding/introduction.md | 6 ++++++ 2 files changed, 17 insertions(+), 2 deletions(-) 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/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). From 8ef336a0c4042b4c833c1054ea82cc417b2c5bdf Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 19 May 2026 17:50:21 +0300 Subject: [PATCH 03/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Return=20cli?= =?UTF-8?q?ent=20error=20when=20unable=20to=20acquire=20DB=20connection"?= =?UTF-8?q?=20(#74409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return client error when unable to acquire DB connection (#74088) * return client error for bad db details * add test * fix kondo * fix test * ignore kondo * fix h2 and sqlite * mock getConnection error * fix kondo unused params Co-authored-by: Riley Thompson --- src/metabase/driver/sql_jdbc/execute.clj | 10 +++++++- src/metabase/driver_api/core.clj | 1 + src/metabase/query_processor/error_type.clj | 5 ++++ .../metabase/driver/sql_jdbc/execute_test.clj | 22 ++++++++++++++++++ test/metabase/test/data/interface.clj | 23 ++++++++----------- 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/metabase/driver/sql_jdbc/execute.clj b/src/metabase/driver/sql_jdbc/execute.clj index 1ef4933b8ffc..e496883651b9 100644 --- a/src/metabase/driver/sql_jdbc/execute.clj +++ b/src/metabase/driver/sql_jdbc/execute.clj @@ -351,7 +351,15 @@ (binding [*connection-recursion-depth* (inc *connection-recursion-depth*)] (if-let [conn (:connection db-or-id-or-spec)] (f conn) - (let [get-conn (^:once fn* [] (.getConnection (do-with-resolved-connection-data-source driver db-or-id-or-spec options)))] + (let [get-conn (^:once fn* [] + (let [conn-data-source (do-with-resolved-connection-data-source driver db-or-id-or-spec options)] + (try + (.getConnection conn-data-source) + (catch Throwable e + (throw (ex-info (tru "Unable to connect to the database: {0}" (ex-message e)) + {:type driver-api/qp.error-type.unable-to-acquire-connection + :driver driver} + e))))))] (if (:keep-open? options) (f (get-conn)) (with-open [conn ^Connection (get-conn)] diff --git a/src/metabase/driver_api/core.clj b/src/metabase/driver_api/core.clj index a736e4e29870..4f321799e0a0 100644 --- a/src/metabase/driver_api/core.clj +++ b/src/metabase/driver_api/core.clj @@ -202,6 +202,7 @@ (p/import-def qp.error-type/invalid-query qp.error-type.invalid-query) (p/import-def qp.error-type/missing-required-parameter qp.error-type.missing-required-parameter) (p/import-def qp.error-type/qp qp.error-type.qp) +(p/import-def qp.error-type/unable-to-acquire-connection qp.error-type.unable-to-acquire-connection) (p/import-def qp.error-type/unsupported-feature qp.error-type.unsupported-feature) (def schema.common.non-blank-string diff --git a/src/metabase/query_processor/error_type.clj b/src/metabase/query_processor/error_type.clj index 8ee728779e6e..ca02647042d6 100644 --- a/src/metabase/query_processor/error_type.clj +++ b/src/metabase/query_processor/error_type.clj @@ -109,6 +109,11 @@ :parent invalid-query :show-in-embeds? true) +(deferror unable-to-acquire-connection + "The query references a database that we are unable to acquire a connection to." + :parent client + :show-in-embeds? true) + ;;;; ### Server-Side Errors (deferror server diff --git a/test/metabase/driver/sql_jdbc/execute_test.clj b/test/metabase/driver/sql_jdbc/execute_test.clj index e8315d1d594b..1e26ec3915a4 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) @@ -214,3 +216,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/test/data/interface.clj b/test/metabase/test/data/interface.clj index ce18c05ec721..5ffccf83c4b1 100644 --- a/test/metabase/test/data/interface.clj +++ b/test/metabase/test/data/interface.clj @@ -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] From ec7263ea6af8215b04fbf96d2b689d015b1c1184 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 19 May 2026 17:54:46 +0300 Subject: [PATCH 04/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"docs:=20ai?= =?UTF-8?q?=20updates"=20(#74426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs: ai updates (#74388) * ai updates * Apply suggestions from code review * links --------- Co-authored-by: Alex Yarosh Co-authored-by: Jeff Bruemmer --- docs/ai/agent-api.md | 9 ++-- docs/ai/mcp.md | 34 +++++++++++++- docs/ai/overview.md | 4 +- docs/ai/settings.md | 108 +++++++++++++++++++++---------------------- docs/ai/start.md | 3 -- 5 files changed, 93 insertions(+), 65 deletions(-) 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. - From 401a457a0eb94c9bf4c3a24d2ad9629bfe9e4504 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 19 May 2026 18:10:22 +0300 Subject: [PATCH 05/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Defensively?= =?UTF-8?q?=20support=20double-encoded=20search=20index=20rows"=20(#74414)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defensively support double-encoded search index rows (#74402) Co-authored-by: Chris Truter --- .../semantic_search/index.clj | 7 ++++++- .../semantic_search/index_test.clj | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/index.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/index.clj index 7810e1892a5d..4462e2206301 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?` diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/index_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/index_test.clj index 91aaeaf871aa..2bf18c526777 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/index_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/index_test.clj @@ -11,7 +11,11 @@ [metabase.collections.models.collection :as collection] [metabase.models.interface :as mi] [metabase.test :as mt] - [metabase.util :as u])) + [metabase.util :as u]) + (:import + (org.postgresql.util PGobject))) + +(set! *warn-on-reflection* true) (use-fixtures :once #'semantic.tu/once-fixture) @@ -558,6 +562,17 @@ (is (false? (#'semantic.index/to-boolean 0))) (is (true? (#'semantic.index/to-boolean 1)))))) +(deftest decode-legacy-input-test + (letfn [(pgo [s] (doto (PGobject.) (.setType "jsonb") (.setValue s)))] + (testing "JSONB object value decodes to a map" + (is (= {:legacy_input {:name "Top 10" :model "card"}} + (#'semantic.index/decode-legacy-input + {:legacy_input (pgo "{\"name\":\"Top 10\",\"model\":\"card\"}")})))) + (testing "BOT-1543: JSONB string value (double-encoded) is decoded again" + (is (= {:legacy_input {:name "Pro" :model "collection"}} + (#'semantic.index/decode-legacy-input + {:legacy_input (pgo "\"{\\\"name\\\":\\\"Pro\\\",\\\"model\\\":\\\"collection\\\"}\"")})))))) + (deftest doc->db-record-boolean-conversion-test (testing "doc->db-record properly converts boolean fields using to-boolean" (let [embedding-vec [0.1 0.2 0.3] From 868cdf9de33269eb17c23b0dc23ce89f81aa2d83 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 19 May 2026 18:22:14 +0300 Subject: [PATCH 06/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Update=20Sec?= =?UTF-8?q?urity=20Center=20promo=20card=20layout"=20(#74421)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update Security Center promo card layout (#73918) * wip * Fix linter * Remove old icon * Fix channels refetching when Slack is setup * Update SecurityCenterPromoCard tests for new copy * GDGT-2345: Add SecurityCenterPromoCard banner to Admin's SettingsNav * GDGT-2345: Fix unit tests for Admin notifications * Add flag check for security center banner in SettingsNav * GDGT-2345: Add new icon typing --------- Co-authored-by: Stas Gavrylov Co-authored-by: Stas Gavrylov --- .../SecurityCenterBanner.tsx | 41 +---- .../SecurityCenterBanner.unit.spec.tsx | 45 +----- .../SecurityCenterPromoCard.tsx | 84 +++++++++++ .../SecurityCenterPromoCard.unit.spec.tsx | 141 ++++++++++++++++++ .../security_center/index.ts | 2 + .../components/SettingsNav/SettingsNav.tsx | 17 ++- .../metabase/admin/settings/tests/setup.tsx | 9 ++ frontend/src/metabase/api/slack.ts | 7 +- .../NavbarPromoCard.module.css | 19 +++ .../NavbarPromoCard/NavbarPromoCard.tsx | 76 ++++++++++ .../nav/components/NavbarPromoCard/index.ts | 1 + .../NavbarPromoSlot.module.css | 15 ++ .../NavbarPromoSlot/NavbarPromoSlot.tsx | 15 ++ .../nav/components/NavbarPromoSlot/index.ts | 1 + .../WhatsNewNotification.module.css | 7 - .../WhatsNewNotification.tsx | 42 ++---- .../MainNavbar/MainNavbar.styled.tsx | 2 +- .../nav/containers/MainNavbar/MainNavbar.tsx | 2 + .../MainNavbarContainer/MainNavbarView.tsx | 4 - .../metabase/plugins/oss/security-center.ts | 2 + .../ui/components/icons/Icon/icons/index.ts | 7 + .../icons/Icon/icons/shield_stroke.svg | 4 + 22 files changed, 419 insertions(+), 124 deletions(-) create mode 100644 enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterPromoCard/SecurityCenterPromoCard.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterPromoCard/SecurityCenterPromoCard.unit.spec.tsx create mode 100644 frontend/src/metabase/nav/components/NavbarPromoCard/NavbarPromoCard.module.css create mode 100644 frontend/src/metabase/nav/components/NavbarPromoCard/NavbarPromoCard.tsx create mode 100644 frontend/src/metabase/nav/components/NavbarPromoCard/index.ts create mode 100644 frontend/src/metabase/nav/components/NavbarPromoSlot/NavbarPromoSlot.module.css create mode 100644 frontend/src/metabase/nav/components/NavbarPromoSlot/NavbarPromoSlot.tsx create mode 100644 frontend/src/metabase/nav/components/NavbarPromoSlot/index.ts delete mode 100644 frontend/src/metabase/nav/components/WhatsNewNotification/WhatsNewNotification.module.css create mode 100644 frontend/src/metabase/ui/components/icons/Icon/icons/shield_stroke.svg diff --git a/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterBanner/SecurityCenterBanner.tsx b/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterBanner/SecurityCenterBanner.tsx index e947f34df9ec..f91b7681b63c 100644 --- a/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterBanner/SecurityCenterBanner.tsx +++ b/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterBanner/SecurityCenterBanner.tsx @@ -1,4 +1,3 @@ -import { useCallback, useState } from "react"; import { Link } from "react-router"; import { jt, t } from "ttag"; @@ -13,21 +12,6 @@ import { Anchor, Text } from "metabase/ui"; import { isAffected } from "../../utils"; -const DISMISSED_KEY = "security-center-banner-dismissed"; - -function useDismissed() { - const [dismissed, setDismissedState] = useState( - () => localStorage.getItem(DISMISSED_KEY) === "true", - ); - - const dismiss = useCallback(() => { - localStorage.setItem(DISMISSED_KEY, "true"); - setDismissedState(true); - }, []); - - return { dismissed, dismiss }; -} - export function SecurityCenterBanner() { const tokenFeatures = useSetting("token-features"); const plan = getPlan(tokenFeatures); @@ -35,7 +19,6 @@ export function SecurityCenterBanner() { useGetChannelInfoQuery(); const { data: advisoriesResponse, isLoading: isAdvisoriesLoading } = useListSecurityAdvisoriesQuery(); - const { dismissed, dismiss } = useDismissed(); if (plan !== "pro-self-hosted") { return null; @@ -55,7 +38,7 @@ export function SecurityCenterBanner() { const advisories = advisoriesResponse?.advisories ?? []; const hasActiveAdvisory = advisories.some(isAffected); - if (dismissed && !hasActiveAdvisory) { + if (!hasActiveAdvisory) { return null; } @@ -75,30 +58,12 @@ export function SecurityCenterBanner() { // eslint-disable-next-line metabase/no-literal-metabase-strings -- only visible to admins on self-hosted instances const body = jt`Please configure notification channels in the ${securityCenterLink} so that you get notified about security vulnerabilities in your Metabase instance`; - if (hasActiveAdvisory) { - return ( - {body}} - py="md" - /> - ); - } - return ( - {body} - - } - closable - onClose={dismiss} + bg="error" + body={{body}} py="md" /> ); diff --git a/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterBanner/SecurityCenterBanner.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterBanner/SecurityCenterBanner.unit.spec.tsx index 8cf7fbb96092..ed5d15d4e73f 100644 --- a/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterBanner/SecurityCenterBanner.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterBanner/SecurityCenterBanner.unit.spec.tsx @@ -14,8 +14,6 @@ import { createAdvisory } from "metabase-types/api/mocks/security-center"; import { SecurityCenterBanner } from "./SecurityCenterBanner"; -const DISMISSED_KEY = "security-center-banner-dismissed"; - interface SetupOpts { isProSelfHosted?: boolean; emailConfigured?: boolean; @@ -60,22 +58,16 @@ function setup({ } describe("SecurityCenterBanner", () => { - afterEach(() => { - localStorage.removeItem(DISMISSED_KEY); - }); - - it("renders warning banner when no channels are configured", async () => { + it("does not render when there is no active advisory", async () => { setup(); - expect( - await screen.findByText(/Please configure notification channels/), - ).toBeInTheDocument(); + await screen.findByText(() => false).catch(() => {}); + expect(screen.queryByTestId("app-banner")).not.toBeInTheDocument(); }); it("does not render when email is configured", async () => { setup({ emailConfigured: true }); - // Wait for API responses to settle, then assert no banner await screen.findByText(() => false).catch(() => {}); expect(screen.queryByTestId("app-banner")).not.toBeInTheDocument(); }); @@ -104,47 +96,20 @@ describe("SecurityCenterBanner", () => { ).toBeInTheDocument(); }); - it("is dismissible when there are no active advisories", async () => { - setup(); - - await screen.findByTestId("app-banner"); - expect(screen.getByLabelText("close icon")).toBeInTheDocument(); - }); - - it("is not dismissible when there are active advisories", async () => { + it("is not dismissible", async () => { setup({ advisories: [createAdvisory({ match_status: "active" })], }); - // Wait for the error banner text to confirm advisory data has loaded await screen.findByText(/Please configure notification channels/); expect(screen.queryByLabelText("close icon")).not.toBeInTheDocument(); }); - it("stays hidden after dismissal when there are no active advisories", async () => { - localStorage.setItem(DISMISSED_KEY, "true"); - - setup(); - - await screen.findByText(() => false).catch(() => {}); - expect(screen.queryByTestId("app-banner")).not.toBeInTheDocument(); - }); - - it("shows banner despite dismissal when there are active advisories", async () => { - localStorage.setItem(DISMISSED_KEY, "true"); - + it("includes a link to security center notification settings", async () => { setup({ advisories: [createAdvisory({ match_status: "active" })], }); - expect( - await screen.findByText(/Please configure notification channels/), - ).toBeInTheDocument(); - }); - - it("includes a link to security center notification settings", async () => { - setup(); - const link = await screen.findByText("Security center"); expect(link).toHaveAttribute( "href", diff --git a/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterPromoCard/SecurityCenterPromoCard.tsx b/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterPromoCard/SecurityCenterPromoCard.tsx new file mode 100644 index 000000000000..76fa9674c6bc --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterPromoCard/SecurityCenterPromoCard.tsx @@ -0,0 +1,84 @@ +import { useCallback, useState } from "react"; +import { t } from "ttag"; + +import { + useGetChannelInfoQuery, + useListSecurityAdvisoriesQuery, +} from "metabase/api"; +import { useSetting } from "metabase/common/hooks"; +import { getPlan } from "metabase/common/utils/plan"; +import { NavbarPromoCard } from "metabase/nav/components/NavbarPromoCard"; +import { Icon } from "metabase/ui"; + +import { isAffected } from "../../utils"; + +const DISMISSED_KEY = "security-center-promo-dismissed"; + +function useDismissed() { + const [dismissed, setDismissedState] = useState( + () => localStorage.getItem(DISMISSED_KEY) === "true", + ); + + const dismiss = useCallback(() => { + localStorage.setItem(DISMISSED_KEY, "true"); + setDismissedState(true); + }, []); + + return { dismissed, dismiss }; +} + +export function SecurityCenterPromoCard() { + const tokenFeatures = useSetting("token-features"); + const plan = getPlan(tokenFeatures); + const { data: channelInfo, isLoading: isChannelInfoLoading } = + useGetChannelInfoQuery(); + const { data: advisoriesResponse, isLoading: isAdvisoriesLoading } = + useListSecurityAdvisoriesQuery(); + const { dismissed, dismiss } = useDismissed(); + + if (plan !== "pro-self-hosted") { + return null; + } + + if (isChannelInfoLoading || isAdvisoriesLoading) { + return null; + } + + const hasEmail = !!channelInfo?.channels?.email?.configured; + const hasSlack = !!channelInfo?.channels?.slack?.configured; + + if (hasEmail || hasSlack) { + return null; + } + + const advisories = advisoriesResponse?.advisories ?? []; + const hasActiveAdvisory = advisories.some(isAffected); + + // Active advisories are shown in the red top-of-page banner instead. + if (hasActiveAdvisory) { + return null; + } + + if (dismissed) { + return null; + } + + return ( + + } + title={t`Stay safe with security alerts`} + // eslint-disable-next-line metabase/no-literal-metabase-strings -- This only shows for admins + body={t`Metabase's Security Center can send you alerts via email or Slack immediately about security vulnerabilities.`} + linkText={t`Set up security alerts`} + linkTo="/admin/security-center?open=notifications" + onDismiss={dismiss} + /> + ); +} diff --git a/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterPromoCard/SecurityCenterPromoCard.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterPromoCard/SecurityCenterPromoCard.unit.spec.tsx new file mode 100644 index 000000000000..aa5ca05dbc8a --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/security_center/components/SecurityCenterPromoCard/SecurityCenterPromoCard.unit.spec.tsx @@ -0,0 +1,141 @@ +import fetchMock from "fetch-mock"; +import { Route } from "react-router"; + +import { setupNotificationChannelsEndpoints } from "__support__/server-mocks"; +import { mockSettings } from "__support__/settings"; +import { renderWithProviders, screen, waitFor } from "__support__/ui"; +import { createMockState } from "metabase/redux/store/mocks"; +import type { Advisory } from "metabase-types/api"; +import { + createMockTokenFeatures, + createMockUser, +} from "metabase-types/api/mocks"; +import { createAdvisory } from "metabase-types/api/mocks/security-center"; + +import { SecurityCenterPromoCard } from "./SecurityCenterPromoCard"; + +const DISMISSED_KEY = "security-center-promo-dismissed"; + +interface SetupOpts { + isProSelfHosted?: boolean; + emailConfigured?: boolean; + slackConfigured?: boolean; + advisories?: Advisory[]; +} + +function setup({ + isProSelfHosted = true, + emailConfigured = false, + slackConfigured = false, + advisories = [], +}: SetupOpts = {}) { + const tokenFeatures = createMockTokenFeatures( + isProSelfHosted + ? { advanced_permissions: true, hosting: false } + : { hosting: false }, + ); + + setupNotificationChannelsEndpoints({ + email: { configured: emailConfigured } as any, + slack: { configured: slackConfigured } as any, + }); + + fetchMock.get("path:/api/ee/security-center", { + last_checked_at: null, + advisories, + }); + + const state = createMockState({ + currentUser: createMockUser({ is_superuser: true }), + settings: mockSettings({ + "token-features": tokenFeatures, + }), + }); + + renderWithProviders(, { + initialRoute: "/", + storeInitialState: state, + withRouter: true, + }); +} + +describe("SecurityCenterPromoCard", () => { + afterEach(() => { + localStorage.removeItem(DISMISSED_KEY); + }); + + it("renders the promo when no channels are configured and no active advisory", async () => { + setup(); + + expect( + await screen.findByText(/Stay safe with security alerts/), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /Set up security alerts/i }), + ).toHaveAttribute("href", "/admin/security-center?open=notifications"); + }); + + it("does not render when email is configured", async () => { + setup({ emailConfigured: true }); + + await screen.findByText(() => false).catch(() => {}); + expect( + screen.queryByText(/Stay safe with security alerts/), + ).not.toBeInTheDocument(); + }); + + it("does not render when slack is configured", async () => { + setup({ slackConfigured: true }); + + await screen.findByText(() => false).catch(() => {}); + expect( + screen.queryByText(/Stay safe with security alerts/), + ).not.toBeInTheDocument(); + }); + + it("does not render for non-pro-self-hosted plans", async () => { + setup({ isProSelfHosted: false }); + + await screen.findByText(() => false).catch(() => {}); + expect( + screen.queryByText(/Stay safe with security alerts/), + ).not.toBeInTheDocument(); + }); + + it("does not render when there is an active advisory (red banner takes over)", async () => { + setup({ + advisories: [createAdvisory({ match_status: "active" })], + }); + + await screen.findByText(() => false).catch(() => {}); + expect( + screen.queryByText(/Stay safe with security alerts/), + ).not.toBeInTheDocument(); + }); + + it("is dismissible", async () => { + setup(); + + await screen.findByText(/Stay safe with security alerts/); + const close = screen.getByRole("button", { name: /close/i }); + close.click(); + + await waitFor(() => { + expect( + screen.queryByText(/Stay safe with security alerts/), + ).not.toBeInTheDocument(); + }); + expect(localStorage.getItem(DISMISSED_KEY)).toBe("true"); + }); + + it("stays hidden after dismissal", async () => { + localStorage.setItem(DISMISSED_KEY, "true"); + + setup(); + + await screen.findByText(() => false).catch(() => {}); + expect( + screen.queryByText(/Stay safe with security alerts/), + ).not.toBeInTheDocument(); + }); +}); diff --git a/enterprise/frontend/src/metabase-enterprise/security_center/index.ts b/enterprise/frontend/src/metabase-enterprise/security_center/index.ts index d96a034ed8c1..de03cb4e2fba 100644 --- a/enterprise/frontend/src/metabase-enterprise/security_center/index.ts +++ b/enterprise/frontend/src/metabase-enterprise/security_center/index.ts @@ -5,12 +5,14 @@ import { SecurityCenterBanner } from "./components/SecurityCenterBanner/Security import { SecurityCenterMobileNavItem } from "./components/SecurityCenterNavItem/SecurityCenterMobileNavItem"; import { SecurityCenterNavItem } from "./components/SecurityCenterNavItem/SecurityCenterNavItem"; import { SecurityCenterPage } from "./components/SecurityCenterPage/SecurityCenterPage"; +import { SecurityCenterPromoCard } from "./components/SecurityCenterPromoCard/SecurityCenterPromoCard"; export function initializePlugin() { if (hasPremiumFeature("admin_security_center")) { PLUGIN_SECURITY_CENTER.isEnabled = true; PLUGIN_SECURITY_CENTER.SecurityCenterPage = SecurityCenterPage; PLUGIN_SECURITY_CENTER.SecurityCenterBanner = SecurityCenterBanner; + PLUGIN_SECURITY_CENTER.SecurityCenterPromoCard = SecurityCenterPromoCard; PLUGIN_SECURITY_CENTER.SecurityCenterNavItem = SecurityCenterNavItem; PLUGIN_SECURITY_CENTER.SecurityCenterMobileNavItem = SecurityCenterMobileNavItem; diff --git a/frontend/src/metabase/admin/settings/components/SettingsNav/SettingsNav.tsx b/frontend/src/metabase/admin/settings/components/SettingsNav/SettingsNav.tsx index 515eb43e7daf..8c23bd755393 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsNav/SettingsNav.tsx +++ b/frontend/src/metabase/admin/settings/components/SettingsNav/SettingsNav.tsx @@ -9,10 +9,10 @@ import { } from "metabase/admin/components/AdminNav"; import { UpsellGem } from "metabase/common/components/upsells/components/UpsellGem"; import { useHasTokenFeature, useSetting } from "metabase/common/hooks"; -import { PLUGIN_REMOTE_SYNC } from "metabase/plugins"; +import { PLUGIN_REMOTE_SYNC, PLUGIN_SECURITY_CENTER } from "metabase/plugins"; import { useSelector } from "metabase/redux"; import { getLocation } from "metabase/selectors/routing"; -import { Divider, Flex } from "metabase/ui"; +import { Box, Divider, Flex } from "metabase/ui"; import { UpdatesNavItem } from "./UpdatesNavItem"; @@ -27,6 +27,8 @@ export function SettingsNav() { const hasScim = useHasTokenFeature("scim"); const hasPythonTransforms = useHasTokenFeature("transforms-python"); const isHosted = useSetting("is-hosted?"); + const { isEnabled: isSecurityCenterEnabled, SecurityCenterPromoCard } = + PLUGIN_SECURITY_CENTER; return ( @@ -115,6 +117,17 @@ export function SettingsNav() { } icon="cloud" /> + {isSecurityCenterEnabled && ( + + + + )} ); } diff --git a/frontend/src/metabase/admin/settings/tests/setup.tsx b/frontend/src/metabase/admin/settings/tests/setup.tsx index 647641ffc333..5c479735378b 100644 --- a/frontend/src/metabase/admin/settings/tests/setup.tsx +++ b/frontend/src/metabase/admin/settings/tests/setup.tsx @@ -10,6 +10,7 @@ import { setupDatabasesEndpoints, setupEmailEndpoints, setupGroupsEndpoint, + setupNotificationChannelsEndpoints, setupPropertiesEndpoints, setupSettingEndpoint, setupSettingsEndpoints, @@ -150,6 +151,14 @@ export const setup = async ({ value: true, }); + setupNotificationChannelsEndpoints({ + email: { configured: false } as any, + slack: { configured: false } as any, + }); + fetchMock.get("path:/api/ee/security-center", { + last_checked_at: null, + advisories: [], + }); fetchMock.get("path:/api/cloud-migration", { status: 204 }); fetchMock.get("path:/api/ee/sso/oidc", []); diff --git a/frontend/src/metabase/api/slack.ts b/frontend/src/metabase/api/slack.ts index f7f81288241e..24081d926431 100644 --- a/frontend/src/metabase/api/slack.ts +++ b/frontend/src/metabase/api/slack.ts @@ -1,6 +1,7 @@ import type { SlackAppInfo, SlackSettings } from "metabase-types/api"; import { Api } from "./api"; +import { listTag } from "./tags"; export const slackApi = Api.injectEndpoints({ endpoints: (builder) => ({ @@ -23,7 +24,11 @@ export const slackApi = Api.injectEndpoints({ url: `/api/slack/settings`, body: settings, }), - invalidatesTags: ["session-properties", "slack-app-info"], + invalidatesTags: [ + "session-properties", + "slack-app-info", + listTag("subscription-channel"), + ], }), }), }); diff --git a/frontend/src/metabase/nav/components/NavbarPromoCard/NavbarPromoCard.module.css b/frontend/src/metabase/nav/components/NavbarPromoCard/NavbarPromoCard.module.css new file mode 100644 index 000000000000..412f5e01225a --- /dev/null +++ b/frontend/src/metabase/nav/components/NavbarPromoCard/NavbarPromoCard.module.css @@ -0,0 +1,19 @@ +.IconWrapper { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--mb-color-brand); +} + +.DismissIconButtonWrapper { + color: var(--mb-color-text-tertiary); +} + +.DismissIconButtonWrapper:hover { + color: var(--mb-color-text-secondary); +} + +.Body { + color: var(--mb-color-text-secondary); +} diff --git a/frontend/src/metabase/nav/components/NavbarPromoCard/NavbarPromoCard.tsx b/frontend/src/metabase/nav/components/NavbarPromoCard/NavbarPromoCard.tsx new file mode 100644 index 000000000000..08ed95b431e4 --- /dev/null +++ b/frontend/src/metabase/nav/components/NavbarPromoCard/NavbarPromoCard.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router"; + +import { IconButtonWrapper } from "metabase/common/components/IconButtonWrapper"; +import { Anchor, Flex, Icon, Paper, Stack, Text } from "metabase/ui"; + +import S from "./NavbarPromoCard.module.css"; + +type LinkTarget = + | { linkTo: string; linkHref?: never } + | { linkHref: string; linkTo?: never }; + +type Props = { + icon: ReactNode; + title: string; + body?: ReactNode; + linkText: string; + onDismiss?: () => void; + external?: boolean; +} & LinkTarget; + +export function NavbarPromoCard({ + icon, + title, + body, + linkText, + linkTo, + linkHref, + onDismiss, + external, +}: Props) { + return ( + + + + {icon} + {onDismiss && ( + + + + )} + + + + + {title} + + {body && ( + + {body} + + )} + + + {linkHref ? ( + + {linkText} + + ) : ( + + {linkText} + + )} + + + ); +} diff --git a/frontend/src/metabase/nav/components/NavbarPromoCard/index.ts b/frontend/src/metabase/nav/components/NavbarPromoCard/index.ts new file mode 100644 index 000000000000..4acfc51f6ecc --- /dev/null +++ b/frontend/src/metabase/nav/components/NavbarPromoCard/index.ts @@ -0,0 +1 @@ +export { NavbarPromoCard } from "./NavbarPromoCard"; diff --git a/frontend/src/metabase/nav/components/NavbarPromoSlot/NavbarPromoSlot.module.css b/frontend/src/metabase/nav/components/NavbarPromoSlot/NavbarPromoSlot.module.css new file mode 100644 index 000000000000..bbba1abecba9 --- /dev/null +++ b/frontend/src/metabase/nav/components/NavbarPromoSlot/NavbarPromoSlot.module.css @@ -0,0 +1,15 @@ +.PromoSlot { + position: sticky; + bottom: 0; + display: flex; + flex-direction: column; + gap: var(--mantine-spacing-md); + padding: var(--mantine-spacing-md) var(--mantine-spacing-md) + calc(var(--mantine-spacing-md) * 2); + background-color: var(--mb-color-background-primary); + z-index: 1; +} + +.PromoSlot:empty { + display: none; +} diff --git a/frontend/src/metabase/nav/components/NavbarPromoSlot/NavbarPromoSlot.tsx b/frontend/src/metabase/nav/components/NavbarPromoSlot/NavbarPromoSlot.tsx new file mode 100644 index 000000000000..13a03f264e43 --- /dev/null +++ b/frontend/src/metabase/nav/components/NavbarPromoSlot/NavbarPromoSlot.tsx @@ -0,0 +1,15 @@ +import { WhatsNewNotification } from "metabase/nav/components/WhatsNewNotification"; +import { PLUGIN_SECURITY_CENTER } from "metabase/plugins"; + +import S from "./NavbarPromoSlot.module.css"; + +export function NavbarPromoSlot() { + const { SecurityCenterPromoCard } = PLUGIN_SECURITY_CENTER; + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/metabase/nav/components/NavbarPromoSlot/index.ts b/frontend/src/metabase/nav/components/NavbarPromoSlot/index.ts new file mode 100644 index 000000000000..3eb054e0a276 --- /dev/null +++ b/frontend/src/metabase/nav/components/NavbarPromoSlot/index.ts @@ -0,0 +1 @@ +export { NavbarPromoSlot } from "./NavbarPromoSlot"; diff --git a/frontend/src/metabase/nav/components/WhatsNewNotification/WhatsNewNotification.module.css b/frontend/src/metabase/nav/components/WhatsNewNotification/WhatsNewNotification.module.css deleted file mode 100644 index 78b29b2c6f9f..000000000000 --- a/frontend/src/metabase/nav/components/WhatsNewNotification/WhatsNewNotification.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.DismissIconButtonWrapper { - color: var(--mb-color-background-tertiary); -} - -.DismissIconButtonWrapper:hover { - color: var(--mb-color-text-secondary); -} diff --git a/frontend/src/metabase/nav/components/WhatsNewNotification/WhatsNewNotification.tsx b/frontend/src/metabase/nav/components/WhatsNewNotification/WhatsNewNotification.tsx index 12b2d18da93d..8bcbe53271f0 100644 --- a/frontend/src/metabase/nav/components/WhatsNewNotification/WhatsNewNotification.tsx +++ b/frontend/src/metabase/nav/components/WhatsNewNotification/WhatsNewNotification.tsx @@ -2,16 +2,13 @@ import { useCallback, useMemo } from "react"; import { t } from "ttag"; import { useGetVersionInfoQuery } from "metabase/api"; -import { IconButtonWrapper } from "metabase/common/components/IconButtonWrapper"; import { useSetting } from "metabase/common/hooks"; +import { NavbarPromoCard } from "metabase/nav/components/NavbarPromoCard"; import { useDispatch, useSelector } from "metabase/redux"; import { updateSetting } from "metabase/redux/settings"; import { getIsEmbeddingIframe } from "metabase/selectors/embed"; import { getIsWhiteLabeling } from "metabase/selectors/whitelabel"; -import { Anchor, Flex, Icon, Paper, Stack, Text } from "metabase/ui"; -import { color } from "metabase/ui/utils/colors"; -import S from "./WhatsNewNotification.module.css"; import Sparkles from "./sparkles.svg?component"; import { getLatestEligibleReleaseNotes } from "./utils"; @@ -53,33 +50,16 @@ export function WhatsNewNotification() { if (!url) { return null; } - return ( - - - - - - - - - - {/* eslint-disable-next-line metabase/no-literal-metabase-strings -- This only shows for admins */} - {t`Metabase has been updated`} - - {t`See what's new`} - - - + return ( + } + // eslint-disable-next-line metabase/no-literal-metabase-strings -- This only shows for admins + title={t`Metabase has been updated`} + linkText={t`See what's new`} + linkHref={url} + external + onDismiss={dismiss} + /> ); } diff --git a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.styled.tsx b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.styled.tsx index 105f64f4e0ac..b1e005dfc618 100644 --- a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.styled.tsx +++ b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.styled.tsx @@ -50,7 +50,7 @@ export const NavRoot = styled.nav<{ isOpen: boolean }>` overflow-y: auto; ${breakpointMinSmall} { - width: ${(props) => (props.isOpen ? NAV_SIDEBAR_WIDTH : 0)}; + width: ${(props) => (props.isOpen ? "100%" : 0)}; } ${breakpointMaxSmall} { diff --git a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.tsx b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.tsx index d4953f2e65b1..07188ab5f3a6 100644 --- a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.tsx +++ b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.tsx @@ -9,6 +9,7 @@ import { useGetCollectionQuery, } from "metabase/api"; import { getDashboard } from "metabase/dashboard/selectors"; +import { NavbarPromoSlot } from "metabase/nav/components/NavbarPromoSlot"; import { connect } from "metabase/redux"; import { closeNavbar, openNavbar } from "metabase/redux/app"; import type { State } from "metabase/redux/store"; @@ -139,6 +140,7 @@ function MainNavbar({ onChangeLocation={onChangeLocation} {...props} /> + ); diff --git a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbarContainer/MainNavbarView.tsx b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbarContainer/MainNavbarView.tsx index af8fbf47b960..7abc5487070b 100644 --- a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbarContainer/MainNavbarView.tsx +++ b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbarContainer/MainNavbarView.tsx @@ -21,7 +21,6 @@ import { getCanAccessOnboardingPage, getIsNewInstance, } from "metabase/home/selectors"; -import { WhatsNewNotification } from "metabase/nav/components/WhatsNewNotification"; import { PLUGIN_REMOTE_SYNC, PLUGIN_TENANTS } from "metabase/plugins"; import { useSelector } from "metabase/redux"; import { @@ -328,9 +327,6 @@ export function MainNavbarView({ )} -
- -
diff --git a/frontend/src/metabase/plugins/oss/security-center.ts b/frontend/src/metabase/plugins/oss/security-center.ts index cf3ec9b1fdda..e83715b6ac8d 100644 --- a/frontend/src/metabase/plugins/oss/security-center.ts +++ b/frontend/src/metabase/plugins/oss/security-center.ts @@ -10,6 +10,7 @@ type SecurityCenterPlugin = { isEnabled: boolean; SecurityCenterPage: ComponentType; SecurityCenterBanner: ComponentType; + SecurityCenterPromoCard: ComponentType; SecurityCenterNavItem: ComponentType; SecurityCenterMobileNavItem: ComponentType; }; @@ -18,6 +19,7 @@ const getDefaultPlugin = (): SecurityCenterPlugin => ({ isEnabled: false, SecurityCenterPage: PluginPlaceholder, SecurityCenterBanner: PluginPlaceholder, + SecurityCenterPromoCard: PluginPlaceholder, SecurityCenterNavItem: PluginPlaceholder, SecurityCenterMobileNavItem: PluginPlaceholder, }); diff --git a/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts b/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts index 59a5769035a7..6cee0ff07d85 100644 --- a/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts +++ b/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts @@ -446,6 +446,8 @@ import shield_component from "./shield.svg?component"; import shield_source from "./shield.svg?source"; import shield_outline_component from "./shield_outline.svg?component"; import shield_outline_source from "./shield_outline.svg?source"; +import shield_stroke_component from "./shield_stroke.svg?component"; +import shield_stroke_source from "./shield_stroke.svg?source"; import sidebar_closed_component from "./sidebar_closed.svg?component"; import sidebar_closed_source from "./sidebar_closed.svg?source"; import sidebar_open_component from "./sidebar_open.svg?component"; @@ -1487,6 +1489,10 @@ export const Icons: Record = component: shield_outline_component, source: shield_outline_source, }, + shield_stroke: { + component: shield_stroke_component, + source: shield_stroke_source, + }, sidebar_closed: { component: sidebar_closed_component, source: sidebar_closed_source, @@ -1969,6 +1975,7 @@ export type IconName = | "segment" | "shield" | "shield_outline" + | "shield_stroke" | "sidebar_closed" | "sidebar_open" | "slack" diff --git a/frontend/src/metabase/ui/components/icons/Icon/icons/shield_stroke.svg b/frontend/src/metabase/ui/components/icons/Icon/icons/shield_stroke.svg new file mode 100644 index 000000000000..7fb52ea371e7 --- /dev/null +++ b/frontend/src/metabase/ui/components/icons/Icon/icons/shield_stroke.svg @@ -0,0 +1,4 @@ + + + + From f2b13bb769bee7be8034fd57a1c707827f4a6910 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 19 May 2026 18:48:16 +0300 Subject: [PATCH 07/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Add=20CSP=20?= =?UTF-8?q?nonce=20to=20SAML=20login=20popup=20for=20embedding"=20(#74212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add CSP nonce to SAML login popup for embedding (#73861) * Add CSP nonce to SAML login popup for embedding * Add tests --------- Co-authored-by: Mahatthana (Kelvin) Nomsawadi * Port necessary missing csp change --------- Co-authored-by: Rodrigo López Dato Co-authored-by: Mahatthana (Kelvin) Nomsawadi --- .../sso/integrations/saml.clj | 2 +- .../sso/integrations/saml_utils.clj | 8 +-- .../sso/integrations/saml_test.clj | 62 +++++++++++++++++++ .../sso/integrations/saml_utils_test.clj | 19 ++++++ src/metabase/server/middleware/security.clj | 2 + 5 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 enterprise/backend/test/metabase_enterprise/sso/integrations/saml_utils_test.clj diff --git a/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj b/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj index 8108eea1aaf4..431ed1f294f8 100644 --- a/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj +++ b/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj @@ -191,7 +191,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 - "}} "" - ;; 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", 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/public_sharing_rest/api_test.clj b/test/metabase/public_sharing_rest/api_test.clj index 5d2252b4f4ef..2c1f5cbfe11a 100644 --- a/test/metabase/public_sharing_rest/api_test.clj +++ b/test/metabase/public_sharing_rest/api_test.clj @@ -1481,7 +1481,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])))) diff --git a/test/metabase/queries_rest/api/card_test.clj b/test/metabase/queries_rest/api/card_test.clj index d82def23965e..2aa5402c934a 100644 --- a/test/metabase/queries_rest/api/card_test.clj +++ b/test/metabase/queries_rest/api/card_test.clj @@ -923,7 +923,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] @@ -2878,7 +2878,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) @@ -3658,8 +3658,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"]}, @@ -4369,7 +4369,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 +4388,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" 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/middleware/cache_test.clj b/test/metabase/query_processor/middleware/cache_test.clj index d53385640866..e9a546db8a50 100644 --- a/test/metabase/query_processor/middleware/cache_test.clj +++ b/test/metabase/query_processor/middleware/cache_test.clj @@ -322,7 +322,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 +359,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/fetch_source_query_test.clj b/test/metabase/query_processor/middleware/fetch_source_query_test.clj index f5b2e41819a2..fac9d4f8fcb9 100644 --- a/test/metabase/query_processor/middleware/fetch_source_query_test.clj +++ b/test/metabase/query_processor/middleware/fetch_source_query_test.clj @@ -129,7 +129,7 @@ (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/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/permissions_test.clj b/test/metabase/query_processor/middleware/permissions_test.clj index 6b2685772d3d..240537900642 100644 --- a/test/metabase/query_processor/middleware/permissions_test.clj +++ b/test/metabase/query_processor/middleware/permissions_test.clj @@ -842,7 +842,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})] 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/streaming_test.clj b/test/metabase/query_processor/streaming_test.clj index bfbc49cd4f1d..d0d7c95d8d3c 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"]] @@ -764,7 +764,7 @@ (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)))] diff --git a/test/metabase/query_processor/timeseries_test.clj b/test/metabase/query_processor/timeseries_test.clj index 68ed6609cdc5..09039001de09 100644 --- a/test/metabase/query_processor/timeseries_test.clj +++ b/test/metabase/query_processor/timeseries_test.clj @@ -691,7 +691,7 @@ ["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 +705,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 @@ -852,7 +852,7 @@ (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 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/revisions/api_test.clj b/test/metabase/revisions/api_test.clj index bc38b097a5c6..a56daf1c4c04 100644 --- a/test/metabase/revisions/api_test.clj +++ b/test/metabase/revisions/api_test.clj @@ -354,7 +354,7 @@ (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) @@ -404,7 +404,7 @@ (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) @@ -451,7 +451,7 @@ :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})) diff --git a/test/metabase/revisions/events_test.clj b/test/metabase/revisions/events_test.clj index ab98e4cab6f6..10f5bc8721ff 100644 --- a/test/metabase/revisions/events_test.clj +++ b/test/metabase/revisions/events_test.clj @@ -133,8 +133,8 @@ (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/dashboard_test.clj b/test/metabase/revisions/impl/dashboard_test.clj index 75d1aab770d6..d32f9168feaa 100644 --- a/test/metabase/revisions/impl/dashboard_test.clj +++ b/test/metabase/revisions/impl/dashboard_test.clj @@ -306,7 +306,7 @@ (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) @@ -332,7 +332,7 @@ (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) @@ -472,7 +472,7 @@ ;; 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} @@ -503,7 +503,7 @@ ;; 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} @@ -528,10 +528,10 @@ (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} @@ -540,15 +540,15 @@ :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}] @@ -624,7 +624,7 @@ :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))) diff --git a/test/metabase/search/api_test.clj b/test/metabase/search/api_test.clj index 0a35f1dd9d36..397ddfed7cf9 100644 --- a/test/metabase/search/api_test.clj +++ b/test/metabase/search/api_test.clj @@ -1447,7 +1447,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] diff --git a/test/metabase/search/appdb/index_test.clj b/test/metabase/search/appdb/index_test.clj index 6444b9fdc734..aec331fbc007 100644 --- a/test/metabase/search/appdb/index_test.clj +++ b/test/metabase/search/appdb/index_test.clj @@ -76,14 +76,14 @@ (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,10 +107,10 @@ (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"]]] @@ -133,7 +133,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") diff --git a/test/metabase/search/appdb/scoring_test.clj b/test/metabase/search/appdb/scoring_test.clj index b8a53b9f2f58..3737217794e6 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 diff --git a/test/metabase/search/in_place/filter_test.clj b/test/metabase/search/in_place/filter_test.clj index 3a174111ab53..966f504954ec 100644 --- a/test/metabase/search/in_place/filter_test.clj +++ b/test/metabase/search/in_place/filter_test.clj @@ -198,7 +198,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 +213,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"] 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/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/settings/models/setting_test.clj b/test/metabase/settings/models/setting_test.clj index a826729800a7..8bd0d5ece75a 100644 --- a/test/metabase/settings/models/setting_test.clj +++ b/test/metabase/settings/models/setting_test.clj @@ -1623,7 +1623,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 +1664,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/sso/integrations/slack_connect_test.clj b/test/metabase/sso/integrations/slack_connect_test.clj index 8821d423b083..c51fb2cca361 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}}} diff --git a/test/metabase/sync/field_values_test.clj b/test/metabase/sync/field_values_test.clj index 4ec474258b00..e5254c73203d 100644 --- a/test/metabase/sync/field_values_test.clj +++ b/test/metabase/sync/field_values_test.clj @@ -129,30 +129,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 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/sync_metadata_test.clj b/test/metabase/sync/sync_metadata/fields/sync_metadata_test.clj index a0b9468f4ed4..2ee979df275b 100644 --- a/test/metabase/sync/sync_metadata/fields/sync_metadata_test.clj +++ b/test/metabase/sync/sync_metadata/fields/sync_metadata_test.clj @@ -312,7 +312,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/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/task_history/api_test.clj b/test/metabase/task_history/api_test.clj index 1dd0bbd5f9bd..e21c3cd072d9 100644 --- a/test/metabase/task_history/api_test.clj +++ b/test/metabase/task_history/api_test.clj @@ -293,7 +293,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 +306,7 @@ :task "b" :started_at (t/zoned-date-time)} - ;; task c + ;; task c :model/TaskHistory _ {:status :started @@ -370,7 +370,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 diff --git a/test/metabase/task_history/models/task_run_test.clj b/test/metabase/task_history/models/task_run_test.clj index 7f1758ac0303..5a9a1dbc5020 100644 --- a/test/metabase/task_history/models/task_run_test.clj +++ b/test/metabase/task_history/models/task_run_test.clj @@ -255,7 +255,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_test.clj b/test/metabase/task_test.clj index d5c99e349425..bf95667b3a82 100644 --- a/test/metabase/task_test.clj +++ b/test/metabase/task_test.clj @@ -149,16 +149,16 @@ (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/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 5ffccf83c4b1..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]] 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..a97b4877e7a4 100644 --- a/test/metabase/test/util.clj +++ b/test/metabase/test/util.clj @@ -1573,7 +1573,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/transforms/jobs_test.clj b/test/metabase/transforms/jobs_test.clj index 7b31a0d25d71..f297572207e1 100644 --- a/test/metabase/transforms/jobs_test.clj +++ b/test/metabase/transforms/jobs_test.clj @@ -288,7 +288,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 +493,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 +513,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 +522,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 +534,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 +546,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/upload/impl_test.clj b/test/metabase/upload/impl_test.clj index a7720ff30a41..6448ba95057e 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)] @@ -1482,7 +1482,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) @@ -2033,7 +2033,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 +2053,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 +2075,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)] diff --git a/test/metabase/warehouse_schema_rest/api/table_test.clj b/test/metabase/warehouse_schema_rest/api/table_test.clj index 9f7d6e2b0e29..0f8da2599bf1 100644 --- a/test/metabase/warehouse_schema_rest/api/table_test.clj +++ b/test/metabase/warehouse_schema_rest/api/table_test.clj @@ -265,7 +265,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 +346,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 @@ -668,7 +668,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", diff --git a/test/metabase/warehouses_rest/api_test.clj b/test/metabase/warehouses_rest/api_test.clj index e4b3ef46c9dc..ef09808f2f97 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) @@ -1452,7 +1452,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) @@ -1823,7 +1823,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"] @@ -1997,7 +1997,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]] 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) From 56ad9a188bf2a1f9d80c0dcdf699890b7289ffb8 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Mon, 25 May 2026 11:28:34 +0300 Subject: [PATCH 46/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Pin=20`jest-?= =?UTF-8?q?canvas-mock`"=20(#74576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin `jest-canvas-mock` (#74509) Co-authored-by: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com> --- bun.lock | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/package.json b/package.json index bc501be8e5dc..994e0995246d 100644 --- a/package.json +++ b/package.json @@ -326,7 +326,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", From 5d102e7a949d8a534a20c47cf4e30e2e314435e3 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Mon, 25 May 2026 20:07:18 +0300 Subject: [PATCH 47/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Fix=20Snowfl?= =?UTF-8?q?ake=20python=20transform=20e2e=20test:=20explicit=20target=20sc?= =?UTF-8?q?hema"=20(#74721)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix Snowflake python transform e2e test: explicit target schema (#74716) Co-authored-by: Ngoc Khuat --- .../transforms_python/drivers_test.clj | 2 +- .../snowflake/test/metabase/driver/snowflake_test.clj | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/enterprise/backend/test/metabase_enterprise/transforms_python/drivers_test.clj b/enterprise/backend/test/metabase_enterprise/transforms_python/drivers_test.clj index 610af8e1763b..daf4f3ff5f64 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms_python/drivers_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms_python/drivers_test.clj @@ -24,7 +24,7 @@ (defn- execute-e2e-transform! "Execute an e2e Python transform test using execute-python-transform!" [table-name transform-code source-tables] - (let [schema (when (#{:postgres :bigquery-cloud-sdk} driver/*driver*) + (let [schema (when (#{:postgres :bigquery-cloud-sdk :snowflake} driver/*driver*) (sql.tx/session-schema driver/*driver*)) target {:type "table" :schema schema diff --git a/modules/drivers/snowflake/test/metabase/driver/snowflake_test.clj b/modules/drivers/snowflake/test/metabase/driver/snowflake_test.clj index 1664bd5e00c1..d7436bc9ffd2 100644 --- a/modules/drivers/snowflake/test/metabase/driver/snowflake_test.clj +++ b/modules/drivers/snowflake/test/metabase/driver/snowflake_test.clj @@ -1,5 +1,7 @@ (ns ^:mb/driver-tests metabase.driver.snowflake-test (:require + [buddy.core.codecs :as codecs] + [buddy.core.hash :as buddy-hash] [clojure.data :as data] [clojure.java.jdbc :as jdbc] [clojure.set :as set] @@ -1305,11 +1307,16 @@ (is (true? (sql-jdbc.sync.interface/have-select-privilege? driver/*driver* conn schema table-name)))))))))))) -(defn- get-db-priv-key [db] +(defn- get-db-priv-key + "Returns a SHA-256 digest of the resolved private key file for `db`." + [db] (-> (:details db) (#'driver.snowflake/resolve-private-key) :private_key_file - slurp)) + slurp + (.getBytes "UTF-8") + buddy-hash/sha256 + codecs/bytes->hex)) (defn- get-priv-key-details [details pk-user priv-key-var] (let [priv-key (tx/db-test-env-var-or-throw :snowflake priv-key-var)] From 51fc3f3a0a656b24293cd5e19447422a02eec7d3 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Mon, 25 May 2026 20:44:11 +0300 Subject: [PATCH 48/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Handle=20que?= =?UTF-8?q?ry=20cache=20update=20race"=20(#74647)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle query cache update race (#74587) Closes #73770 Co-authored-by: metamben <103100869+metamben@users.noreply.github.com> --- .../middleware/cache_backend/db.clj | 11 ++--- .../middleware/cache_backend/db_test.clj | 44 ++++++++++++++++++- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/metabase/query_processor/middleware/cache_backend/db.clj b/src/metabase/query_processor/middleware/cache_backend/db.clj index 161d9eea5724..c485c23111df 100644 --- a/src/metabase/query_processor/middleware/cache_backend/db.clj +++ b/src/metabase/query_processor/middleware/cache_backend/db.clj @@ -1,6 +1,7 @@ (ns metabase.query-processor.middleware.cache-backend.db (:require [java-time.api :as t] + [metabase.app-db.core :as app-db] [metabase.premium-features.core :refer [defenterprise]] [metabase.query-processor.middleware.cache-backend.interface :as i] [metabase.util.date-2 :as u.date] @@ -87,13 +88,9 @@ (let [final-results (encryption/maybe-encrypt-for-stream results) timestamp (t/offset-date-time)] (try - (or (pos? (t2/update! :model/QueryCache {:query_hash query-hash} - {:updated_at timestamp - :results final-results})) - (first (t2/insert-returning-instances! :model/QueryCache - :updated_at timestamp - :query_hash query-hash - :results final-results))) + (app-db/update-or-insert! :model/QueryCache {:query_hash query-hash} + (constantly {:updated_at timestamp + :results final-results})) (catch Throwable e (log/error e "Error saving query results to cache."))) nil)) 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)))))))))))) From 23fcfa5edf2165e4544bdc41a7ba7c3167b8dd30 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Mon, 25 May 2026 20:48:32 +0300 Subject: [PATCH 49/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"handle=20dup?= =?UTF-8?q?licate=20copies=20of=20gson=20on=20classpath"=20(#74696)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle duplicate copies of gson on classpath (#74685) * handle duplicate copies of gson on classpath fixes https://github.com/metabase/metabase/issues/73736 The relevant error message for us: ``` java.lang.NoSuchMethodError: 'com.google.gson.stream.JsonWriter com.google.gson.stream.JsonWriter.value(float)' com.google.api.client.json.gson.GsonGenerator.writeNumber(GsonGenerator.java:105) com.google.cloud.bigquery.BigQueryRetryAlgorithm.getErrorDescFromResponse(BigQueryRetryAlgorithm.java:227) com.google.cloud.bigquery.BigQueryRetryAlgorithm.shouldRetryBasedOnBigQueryRetryConfig(BigQueryRetryAlgorithm.java:115) com.google.cloud.bigquery.BigQueryRetryHelper.run(...:108) driver.bigquery_cloud_sdk$execute_bigquery(bigquery_cloud_sdk.clj:751) ``` ```clojure user=> (enumeration-seq (.. (Thread/currentThread) getContextClassLoader (getResources "com/google/gson/stream/JsonWriter.class"))) (#object[java.net.URL "0x3bc8ea4f" "jar:file:/Users/dan/.m2/repository/com/google/code/gson/gson/2.12.1/gson-2.12.1.jar!/com/google/gson/stream/JsonWriter.class"] #object[java.net.URL "0x50073d1c" "jar:file:/Users/dan/.m2/repository/com/vertica/jdbc/vertica-jdbc/23.4.0-0/vertica-jdbc-23.4.0-0.jar!/com/google/gson/stream/JsonWriter.class"]) ``` - Vertica bundles: gson 2.8.9 (vendored, unshaded) - BigQuery demands: gson 2.12.1 (via com.google.code.gson/gson in deps tree — needs JsonWriter.value(float) which was added in 2.9.0) contents of vertica jar: 71 com/vertica/io 70 com/google/gson/internal/bind 61 com/vertica/utilities/conversion 188 gson class files bundled inside vertica-jdbc, vs ~600 Vertica classes. They just vendored the entire gson library unshaded into their fat JAR. Only gson — nothing else third-party. So mystery solved. We had two copies of gson and vertica could slip through. Our fix is to ensure that we only accept (or overwrite) when it comes from a gson lib ```clojure (def ^:private gson-conflict-handler "vertica-jdbc (and potentially other fat JARs) bundle their own copy of gson classes. When these overwrite the correct version from com.google.code.gson/gson, BigQuery's error-handling path crashes with NoSuchMethodError on JsonWriter.value(float), introduced in gson 2.9.0. This handler ensures the pinned gson version always wins regardless of JAR processing order. See #73736." {"com/google/gson/.*" (fn [{:keys [lib path in]}] (if (= lib 'com.google.code.gson/gson) {:write {path {:stream in}}} nil))}) ``` * add activation conflict handler as well ```clojure uberjar-test=> (clojure.test/run-tests) Testing build.uberjar-test Clean Delete /Users/dan/projects/work/metabase/target/classes Delete /Users/dan/projects/work/metabase/target/uberjar/metabase.jar FAIL in (class-file-conflicts-test) (uberjar_test.clj:55) No unexpected class file conflicts in the EE uberjar Unexpected class file conflicts in packages: #{"javax/activation"} If benign, add to known-conflicting-prefixes with a comment. If dangerous, add a conflict handler in build.uberjar. expected: (empty? unexpected) actual: (not (empty? #{"javax/activation"})) Ran 1 tests containing 1 assertions. 1 failures, 0 errors. {:test 1, :pass 0, :fail 1, :error 0, :type :summary} ``` Here's the output when i removed the `activation-conflict-handler` from the uberjar-er. It hits conflicts on these files. It doesn't know where the conflicts come from. The api is aware of the lib trying to write and there's already a conflict. So the order isn't stable and we must match on the files being written, not the lib writing. Because lib A might write first so lib B tries to write the same class we think it's conflict on B. But then B is written first and now it appears a conflict in A. also an exclusion in databricks so it doesn't bring in another logger and we will no longer get this: ``` ❯ clj -J"$(llm-repl 6006)" -M:"$ALIASES":build:build-dev:llm-repl Downloading: vlaaad/reveal/maven-metadata.xml from clojars WARNING: Specified aliases are undeclared and are not being used: [:performance] SLF4J(W): Class path contains multiple SLF4J providers. SLF4J(W): Found provider [org.apache.logging.slf4j.SLF4JServiceProvider@122ea8dc] SLF4J(W): Found provider [org.slf4j.jul.JULServiceProvider@6cb417fc] SLF4J(W): See https://www.slf4j.org/codes.html#multiple_bindings for an explanation. SLF4J(I): Actual provider is of type [org.apache.logging.slf4j.SLF4JServiceProvider@122ea8dc] ``` * fixing it up from comments Co-authored-by: dpsutton --- bin/build/src/build/uberjar.clj | 81 +++++++++++++++++++++++++-- bin/build/test/build/uberjar_test.clj | 59 +++++++++++++++++++ modules/drivers/databricks/deps.edn | 5 +- 3 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 bin/build/test/build/uberjar_test.clj 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/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/modules/drivers/databricks/deps.edn b/modules/drivers/databricks/deps.edn index 1aba2d34c6c0..12f5ed692921 100644 --- a/modules/drivers/databricks/deps.edn +++ b/modules/drivers/databricks/deps.edn @@ -4,9 +4,8 @@ :deps {at.yawk.lz4/lz4-java {:mvn/version "1.10.4"} ; pin newer version because version included in JDBC driver has security warnings com.databricks/databricks-jdbc-thin {:mvn/version "3.3.1" - ;; lang3 is a dep in top-level deps.edn, so not actually used anyway; exclusion - ;; here fixes security warnings about version used in JDBC driver - :exclusions [org.apache.commons/commons-lang3]} + :exclusions [org.apache.commons/commons-lang3 ;; dep in top-level deps.edn; exclusion fixes security warnings + org.slf4j/slf4j-jdk14]} ;; conflicts with our log4j2 SLF4J provider ;; pin Bouncy Castle explicitly so the bundled driver JAR uses the patched version org.bouncycastle/bcpkix-jdk18on {:mvn/version "1.84"} org.bouncycastle/bcprov-jdk18on {:mvn/version "1.84"} From 46ddf6d99cf56b0fc8440b3ccc3a74682ef24cab Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Mon, 25 May 2026 20:49:16 +0300 Subject: [PATCH 50/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Fix=20remapp?= =?UTF-8?q?ing=20with=20custom=20values"=20(#74722)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix remapping with custom values (#74698) * fix remapping with custom values * fix * fix unit test Co-authored-by: Alexander Polyankin --- ...dashboard-filters-number-source.cy.spec.js | 4 +- .../ValuesSourceModal.unit.spec.tsx | 10 +-- src/metabase/parameters/custom_values.clj | 27 +++++--- .../embedding_rest/api/embed_test.clj | 23 +++++++ .../parameters/custom_values_test.clj | 66 +++++++++++++++++++ 5 files changed, 115 insertions(+), 15 deletions(-) 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/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.unit.spec.tsx b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.unit.spec.tsx index 7a40dc9c1932..1d152e86398b 100644 --- a/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.unit.spec.tsx +++ b/frontend/src/metabase/parameters/components/ValuesSourceModal/ValuesSourceModal.unit.spec.tsx @@ -739,7 +739,7 @@ describe("ValuesSourceModal", () => { parameter: createMockUiParameter({ fields: [field1, field2], values_source_config: { - values: [[1], [2]], + values: [["1"], ["2"]], }, }), parameterValues: createMockParameterValues({ @@ -762,7 +762,7 @@ describe("ValuesSourceModal", () => { parameter: createMockUiParameter({ fields: [field1, field2], values_source_config: { - values: [[1], [2]], + values: [["1"], ["2"]], }, }), parameterValues: createMockParameterValues({ @@ -836,7 +836,7 @@ describe("ValuesSourceModal", () => { fields: [field1], values_source_type: "static-list", values_source_config: { - values: [[1], [2]], + values: [["1"], ["2"]], }, }), }); @@ -858,7 +858,7 @@ describe("ValuesSourceModal", () => { fields: [field1], values_source_type: "static-list", values_source_config: { - values: [[1, "Label"], [2]], + values: [["1", "Label"], ["2"]], }, }), }); @@ -882,7 +882,7 @@ describe("ValuesSourceModal", () => { fields: [field1], values_source_type: "static-list", values_source_config: { - values: [[1, "Label"], [2]], + values: [["1", "Label"], ["2"]], }, }), }); diff --git a/src/metabase/parameters/custom_values.clj b/src/metabase/parameters/custom_values.clj index 60d24a88d9b6..6355c28b4c2c 100644 --- a/src/metabase/parameters/custom_values.clj +++ b/src/metabase/parameters/custom_values.clj @@ -23,6 +23,7 @@ [metabase.util.malli :as mu] [metabase.util.malli.registry :as mr] [metabase.util.malli.schema :as ms] + [metabase.util.performance :as perf] [toucan2.core :as t2])) ;;; ------------------------------------------------- source=static-list -------------------------------------------------- @@ -110,10 +111,20 @@ (cond-> query-filter (lib/filter query-filter)) (lib/breakout value-column) ;; add the label as a second breakout so each row is a [value label] pair - (cond-> label-column (lib/breakout label-column)) - ;; TODO(Braden, 07/04/2025): This should probably become a lib helper? I suspect this isn't the only - ;; "internal" query in the BE. - (assoc-in [:middleware :disable-remaps?] true)))))))) + (cond-> label-column (lib/breakout label-column))))))))) + +(defn- result->rows + "Extract rows from a QP result, dropping values for any display columns the QP injected for + remapped breakouts. Those columns are referenced by a `:remapped_to` entry on their source + column; when none are present the raw rows are returned as-is." + [result] + (let [cols (get-in result [:data :cols]) + rows (get-in result [:data :rows]) + drop-names (into #{} (keep :remapped_to) cols)] + (if (empty? drop-names) + rows + (let [keep-idxs (into [] (keep-indexed (fn [i c] (when-not (drop-names (:name c)) i))) cols)] + (perf/mapv (fn [row] (perf/mapv #(nth row %) keep-idxs)) rows))))) (mu/defn values-from-card "Get distinct values of a field from a card. @@ -135,10 +146,10 @@ ([card :- :metabase.queries.schema/card field-ref :- [:or :mbql.clause/field :mbql.clause/expression] opts :- [:maybe ::values-from-card-query.options]] - (let [mbql-query (values-from-card-query card field-ref opts) - result (some-> mbql-query qp/process-query) - values (get-in result [:data :rows])] - {:values (or values []) + (let [mbql-query (values-from-card-query card field-ref opts) + result (some-> mbql-query qp/process-query) + values (some-> result result->rows)] + {:values (or values []) ;; If the row_count returned = the limit we specified, then it's probably has more than that. ;; If the query has its own limit smaller than *max-rows*, then there's no more values. :has_more_values (= (:row_count result) *max-rows*)}))) diff --git a/test/metabase/embedding_rest/api/embed_test.clj b/test/metabase/embedding_rest/api/embed_test.clj index b2ce16db2254..3620567b460a 100644 --- a/test/metabase/embedding_rest/api/embed_test.clj +++ b/test/metabase/embedding_rest/api/embed_test.clj @@ -1729,6 +1729,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_" diff --git a/test/metabase/parameters/custom_values_test.clj b/test/metabase/parameters/custom_values_test.clj index 4be9d12670b6..9fa28d05558e 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])) @@ -415,6 +416,71 @@ 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]] From be3af16eaaaafb9418f0679972ce6ce8792dcf6d Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 26 May 2026 12:18:30 +0300 Subject: [PATCH 51/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"security=20c?= =?UTF-8?q?enter:=20only=20include=20mb=20email=20if=20send=20to=20all=20e?= =?UTF-8?q?mails=20is=20on"=20(#74745)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit security center: only include mb email if send to all emails is on (#74334) only include mb email if send to all emails is on Co-authored-by: Nicola Mometto --- .../security_center/notification.clj | 34 ++++++++++---- .../security_center/notification_test.clj | 45 ++++++++++++++----- 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/enterprise/backend/src/metabase_enterprise/security_center/notification.clj b/enterprise/backend/src/metabase_enterprise/security_center/notification.clj index d5b68ff159c5..f3c969ca3ca2 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,14 +39,30 @@ :path "metabase/channel/email/security_advisory.hbs" :recipient-type "bcc"}}) +(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- email-recipients - "Resolve email recipients: configured recipients from the setting, - plus the site admin email (if set) as a raw-value recipient." + "Resolve email recipients from the `security-center-email-recipients` setting. + 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." [] - (let [configured (or (some-> (settings/security-center-email-recipients) - (t2/hydrate :recipients-detail)) - [])] - (if-let [admin-email (system/admin-email)] + (let [raw (or (settings/security-center-email-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 configured {:type :notification-recipient/raw-value :details {:value admin-email}}) configured))) diff --git a/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj b/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj index dace3b7731ce..c00d405ef9a6 100644 --- a/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj +++ b/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj @@ -9,6 +9,7 @@ [metabase.events.core :as events] [metabase.models.interface :as mi] [metabase.notification.send :as notification.send] + [metabase.permissions.core :as perms] [metabase.test :as mt] [metabase.test.fixtures :as fixtures] [toucan2.core :as t2])) @@ -251,42 +252,62 @@ ;;; -------------------------------------------- Recipient resolution ------------------------------------------------- +(defn- admin-group-recipient [] + {:type :notification-recipient/group :permissions_group_id (:id (perms/admin-group))}) + (deftest admin-email-included-when-set-test - (testing "site admin email is appended as a raw-value recipient" - (let [sent (atom nil) - custom-recip [{:type :notification-recipient/external-email :details {:email "security@example.com"}}]] + (testing "site admin email is appended when recipients target the admin group" + (let [sent (atom nil) + recips [(admin-group-recipient) + {:type :notification-recipient/external-email :details {:email "security@example.com"}}]] (mt/with-temp [:model/SecurityAdvisory advisory (advisory-fixture {:advisory_id "SC-ADMIN-001" :severity "critical" :match_status "active"})] (mt/with-temporary-setting-values [admin-email "boss@example.com"] - (with-redefs [settings/security-center-email-recipients (constantly custom-recip)] + (with-redefs [settings/security-center-email-recipients (constantly recips)] (with-send-redef (fn [notif & _] (reset! sent notif)) (notification/notify-advisory! advisory) (let [email-handler (first (filter #(= :channel/email (:channel_type %)) (:handlers @sent))) recipients (:recipients email-handler)] - (is (= 2 (count recipients))) - ;; configured recipient - (is (= :notification-recipient/external-email (:type (first recipients)))) - ;; admin email appended + ;; admin group recipient + external email + raw admin email + (is (= 3 (count recipients))) (is (= {:type :notification-recipient/raw-value :details {:value "boss@example.com"}} (last recipients)))))))))) (testing "no admin email appended when admin-email setting is nil" - (let [sent (atom nil) - custom-recip [{:type :notification-recipient/external-email :details {:email "security@example.com"}}]] + (let [sent (atom nil) + recips [(admin-group-recipient) + {:type :notification-recipient/external-email :details {:email "security@example.com"}}]] (mt/with-temp [:model/SecurityAdvisory advisory (advisory-fixture {:advisory_id "SC-ADMIN-002" :severity "high" :match_status "active"})] (mt/with-temporary-setting-values [admin-email nil] - (with-redefs [settings/security-center-email-recipients (constantly custom-recip)] + (with-redefs [settings/security-center-email-recipients (constantly recips)] + (with-send-redef (fn [notif & _] (reset! sent notif)) + (notification/notify-advisory! advisory) + (let [email-handler (first (filter #(= :channel/email (:channel_type %)) (:handlers @sent))) + recipients (:recipients email-handler)] + (is (every? #(not= :notification-recipient/raw-value (:type %)) recipients)))))))))) + +(deftest admin-email-excluded-when-send-to-all-admins-off-test + (testing "admin-email is NOT appended when the configured recipient list omits the admin group (GDGT-2422)" + (let [sent (atom nil) + recips [{:type :notification-recipient/external-email :details {:email "security@example.com"}}]] + (mt/with-temp [:model/SecurityAdvisory advisory + (advisory-fixture {:advisory_id "SC-ADMIN-OFF-001" + :severity "critical" + :match_status "active"})] + (mt/with-temporary-setting-values [admin-email "boss@example.com"] + (with-redefs [settings/security-center-email-recipients (constantly recips)] (with-send-redef (fn [notif & _] (reset! sent notif)) (notification/notify-advisory! advisory) (let [email-handler (first (filter #(= :channel/email (:channel_type %)) (:handlers @sent))) recipients (:recipients email-handler)] (is (= 1 (count recipients))) - (is (= :notification-recipient/external-email (:type (first recipients)))))))))))) + (is (= :notification-recipient/external-email (:type (first recipients)))) + (is (every? #(not= :notification-recipient/raw-value (:type %)) recipients)))))))))) (deftest email-recipients-custom-list-test (testing "when security-center-email-recipients is set, those specific recipients are used" From cd95af1715ce51ed9f23f80ac51e6db3d974488c Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 26 May 2026 16:54:47 +0300 Subject: [PATCH 52/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Show=20colle?= =?UTF-8?q?ction=20picker=20when=20saving=20new=20question=20in=20metabase?= =?UTF-8?q?-browser"=20(#74757)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show collection picker when saving new question in metabase-browser (#74570) Co-authored-by: Sébastien --- .../view-and-curate-content.cy.spec.ts | 9 +++-- .../context/SdkQuestionProvider.tsx | 2 ++ .../private/SdkQuestion/context/types.ts | 6 ++++ .../SdkQuestionDefaultView.tsx | 2 ++ .../InteractiveQuestion.schema.ts | 1 + .../public/SdkQuestion/SdkQuestion.tsx | 4 +++ .../components/SaveQuestionForm/context.tsx | 10 +++++- .../SaveQuestionForm/context.unit.spec.tsx | 35 +++++++++++++++++++ .../components/MetabaseBrowser.tsx | 2 +- .../components/QueryModals/QueryModals.tsx | 2 -- 10 files changed, 66 insertions(+), 7 deletions(-) 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/frontend/src/embedding-sdk-bundle/components/private/SdkQuestion/context/SdkQuestionProvider.tsx b/frontend/src/embedding-sdk-bundle/components/private/SdkQuestion/context/SdkQuestionProvider.tsx index 0d6eb97c4879..13dfee7f9ee5 100644 --- a/frontend/src/embedding-sdk-bundle/components/private/SdkQuestion/context/SdkQuestionProvider.tsx +++ b/frontend/src/embedding-sdk-bundle/components/private/SdkQuestion/context/SdkQuestionProvider.tsx @@ -70,6 +70,7 @@ export const SdkQuestionProvider = ({ entityTypes, dataPicker, targetCollection, + initialCollection, initialSqlParameters, hiddenParameters, withDownloads, @@ -265,6 +266,7 @@ export const SdkQuestionProvider = ({ onCreate: handleCreate, isSaveEnabled, targetCollection, + initialCollection, withDownloads, withAlerts, onRun, diff --git a/frontend/src/embedding-sdk-bundle/components/private/SdkQuestion/context/types.ts b/frontend/src/embedding-sdk-bundle/components/private/SdkQuestion/context/types.ts index 8f4a71427bfd..534af9e81474 100644 --- a/frontend/src/embedding-sdk-bundle/components/private/SdkQuestion/context/types.ts +++ b/frontend/src/embedding-sdk-bundle/components/private/SdkQuestion/context/types.ts @@ -64,6 +64,11 @@ type SdkQuestionConfig = { */ targetCollection?: SdkCollectionId; + /** + * The collection to preselect in the save modal's collection picker. Unlike `targetCollection`, the picker remains visible and the user can choose a different collection. Ignored when `targetCollection` is set. + */ + initialCollection?: SdkCollectionId; + /** * Additional mapper function to override or add drill-down menu */ @@ -154,6 +159,7 @@ export type SdkQuestionContextType = Omit< | "onNavigateBack" | "isSaveEnabled" | "targetCollection" + | "initialCollection" | "withDownloads" | "withAlerts" | "backToDashboard" diff --git a/frontend/src/embedding-sdk-bundle/components/private/SdkQuestionDefaultView/SdkQuestionDefaultView.tsx b/frontend/src/embedding-sdk-bundle/components/private/SdkQuestionDefaultView/SdkQuestionDefaultView.tsx index c46ef485d8de..9c491c024007 100644 --- a/frontend/src/embedding-sdk-bundle/components/private/SdkQuestionDefaultView/SdkQuestionDefaultView.tsx +++ b/frontend/src/embedding-sdk-bundle/components/private/SdkQuestionDefaultView/SdkQuestionDefaultView.tsx @@ -300,6 +300,7 @@ const DefaultViewSaveModal = ({ onSave, isSaveEnabled, targetCollection, + initialCollection, } = useSdkQuestionContext(); if (!isSaveEnabled || !isOpen || !question) { @@ -319,6 +320,7 @@ const DefaultViewSaveModal = ({ close(); }} targetCollection={targetCollection} + initialCollectionId={initialCollection} /> ); }; diff --git a/frontend/src/embedding-sdk-bundle/components/public/InteractiveQuestion/InteractiveQuestion.schema.ts b/frontend/src/embedding-sdk-bundle/components/public/InteractiveQuestion/InteractiveQuestion.schema.ts index 2753e814d135..7adf98432dc2 100644 --- a/frontend/src/embedding-sdk-bundle/components/public/InteractiveQuestion/InteractiveQuestion.schema.ts +++ b/frontend/src/embedding-sdk-bundle/components/public/InteractiveQuestion/InteractiveQuestion.schema.ts @@ -44,6 +44,7 @@ const propsSchema: Yup.SchemaOf = Yup.object({ query: Yup.mixed().optional(), style: Yup.mixed().optional(), targetCollection: Yup.mixed().optional(), + initialCollection: Yup.mixed().optional(), targetDashboardId: Yup.mixed().optional(), title: Yup.mixed().optional(), width: Yup.mixed().optional(), diff --git a/frontend/src/embedding-sdk-bundle/components/public/SdkQuestion/SdkQuestion.tsx b/frontend/src/embedding-sdk-bundle/components/public/SdkQuestion/SdkQuestion.tsx index 2b45463e25f9..b1782d9554b3 100644 --- a/frontend/src/embedding-sdk-bundle/components/public/SdkQuestion/SdkQuestion.tsx +++ b/frontend/src/embedding-sdk-bundle/components/public/SdkQuestion/SdkQuestion.tsx @@ -60,6 +60,7 @@ export type BaseSdkQuestionProps = SdkQuestionIdProps & { | "withDownloads" | "withAlerts" | "targetCollection" + | "initialCollection" | "onRun" >; @@ -135,6 +136,7 @@ export const _SdkQuestion = ({ entityTypes, dataPicker, targetCollection, + initialCollection, initialSqlParameters, hiddenParameters, withDownloads = false, @@ -161,6 +163,7 @@ export const _SdkQuestion = ({ withChartTypeSelector, isSaveEnabled, targetCollection, + initialCollection, entityTypes, onBeforeSave, onSave, @@ -191,6 +194,7 @@ export const _SdkQuestion = ({ entityTypes={entityTypes} dataPicker={dataPicker} targetCollection={targetCollection} + initialCollection={initialCollection} initialSqlParameters={initialSqlParameters} hiddenParameters={hiddenParameters} withDownloads={withDownloads} diff --git a/frontend/src/metabase/common/components/SaveQuestionForm/context.tsx b/frontend/src/metabase/common/components/SaveQuestionForm/context.tsx index ade8d336ccc1..746071243ee0 100644 --- a/frontend/src/metabase/common/components/SaveQuestionForm/context.tsx +++ b/frontend/src/metabase/common/components/SaveQuestionForm/context.tsx @@ -70,6 +70,7 @@ export const SaveQuestionProvider = ({ onSave, multiStep = false, targetCollection: userTargetCollection, + initialCollectionId: userInitialCollectionId, children, }: PropsWithChildren) => { const [originalQuestion] = useState(latestOriginalQuestion); // originalQuestion from props changes during saving @@ -121,13 +122,20 @@ export const SaveQuestionProvider = ({ const initialDashboardId = question.type() === "question" && !isAnalytics && - // `userTargetCollection` comes from the `targetCollection` sdk prop and should take precedence over the recent dashboards + // `userTargetCollection` and `userInitialCollectionId` come from the + // `targetCollection` / `initialCollection` sdk props and should take + // precedence over the recent dashboards. userTargetCollection === undefined && + userInitialCollectionId === undefined && lastSelectedDashboard?.can_write ? lastSelectedDashboard?.id : undefined; + // When a caller passes `initialCollectionId` explicitly, it takes precedence + // over the recent-items logic. Used by the SDK so the picker opens on the + // collection the user is browsing. const initialCollectionId = + userInitialCollectionId ?? (!isAnalytics ? lastSelectedDashboard?.parent_collection.id : defaultCollectionId) ?? diff --git a/frontend/src/metabase/common/components/SaveQuestionForm/context.unit.spec.tsx b/frontend/src/metabase/common/components/SaveQuestionForm/context.unit.spec.tsx index 0cbde9c76c46..3474b3d70625 100644 --- a/frontend/src/metabase/common/components/SaveQuestionForm/context.unit.spec.tsx +++ b/frontend/src/metabase/common/components/SaveQuestionForm/context.unit.spec.tsx @@ -46,6 +46,7 @@ const TestComponent = () => { interface setupProps { question?: Question; originalQuestion?: Question | null; + initialCollectionId?: number | string | null; } const setup = ({ @@ -57,6 +58,7 @@ const setup = ({ }), ), originalQuestion = null, + initialCollectionId, }: setupProps = {}) => { const onCreate = jest.fn(); const onSave = jest.fn(); @@ -78,6 +80,7 @@ const setup = ({ originalQuestion={originalQuestion} onCreate={onCreate} onSave={onSave} + initialCollectionId={initialCollectionId} > , @@ -137,6 +140,38 @@ describe("SaveQuestionContext", () => { expect(screen.queryByTestId("dashboardId")).not.toBeInTheDocument(); }); + it("should use the initialCollectionId prop over recent selections (EMB-1609)", async () => { + setupRecentViewsAndSelectionsEndpoints( + [createMockRecentCollectionItem({ model: "collection", id: 10 })], + ["selections"], + ); + + setup({ initialCollectionId: 42 }); + + await waitFor(async () => + expect(await screen.findByTestId("collectionId")).toHaveTextContent( + "42", + ), + ); + expect(screen.queryByTestId("dashboardId")).not.toBeInTheDocument(); + }); + + it("should not suggest a recent dashboard when initialCollectionId is set (EMB-1609)", async () => { + setupRecentViewsAndSelectionsEndpoints( + [createMockRecentCollectionItem({ model: "dashboard", id: 10 })], + ["selections"], + ); + + setup({ initialCollectionId: 42 }); + + await waitFor(async () => + expect(await screen.findByTestId("collectionId")).toHaveTextContent( + "42", + ), + ); + expect(screen.queryByTestId("dashboardId")).not.toBeInTheDocument(); + }); + it("should require saving to a specific dashboard if the question has a dashboard id already", async () => { setupRecentViewsAndSelectionsEndpoints( [createMockRecentCollectionItem({ model: "dashboard", id: 10 })], diff --git a/frontend/src/metabase/embedding/embedding-iframe-sdk/components/MetabaseBrowser.tsx b/frontend/src/metabase/embedding/embedding-iframe-sdk/components/MetabaseBrowser.tsx index 70cc776d9d44..b4d3d86c8cd4 100644 --- a/frontend/src/metabase/embedding/embedding-iframe-sdk/components/MetabaseBrowser.tsx +++ b/frontend/src/metabase/embedding/embedding-iframe-sdk/components/MetabaseBrowser.tsx @@ -92,7 +92,7 @@ export function MetabaseBrowser({ settings }: MetabaseBrowserProps) { withDownloads isSaveEnabled={!isReadOnly} entityTypes={settings.dataPickerEntityTypes} - targetCollection={targetCollection} + initialCollection={targetCollection} /> )) diff --git a/frontend/src/metabase/query_builder/components/QueryModals/QueryModals.tsx b/frontend/src/metabase/query_builder/components/QueryModals/QueryModals.tsx index f8d1f102a3d5..45e45c37640f 100644 --- a/frontend/src/metabase/query_builder/components/QueryModals/QueryModals.tsx +++ b/frontend/src/metabase/query_builder/components/QueryModals/QueryModals.tsx @@ -194,7 +194,6 @@ export function QueryModals({ ); case MODAL_TYPES.MOVE: From 4b655c9bc5d010dfd8aee44a58fd4a5fc7293d1b Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 26 May 2026 17:40:33 +0300 Subject: [PATCH 53/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"feat:=20ui?= =?UTF-8?q?=20to=20display=20data=20complexity=20color=20coding"=20(#74761?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: ui to display data complexity color coding (#74363) Closes [BOT-1519: UI to display data complexity score and color coding](https://linear.app/metabase/issue/BOT-1519/ui-to-display-data-complexity-score-and-color-coding) ### Description Adds UI to display data complexity score and color coding. ### How to verify 1. Go to AI usage analytics page. 2. Go to data complexity scores. ### Demo https://www.loom.com/share/81901a478d1a45a3839d7dd42f3e4ae7?from_recorder=1&focus_title=1 ### Checklist - [x] Tests have been added/updated to cover changes in this PR - [x] If adding new Loki tests: they pass [stress testing](https://github.com/metabase/metabase/actions/workflows/loki-stress-test-flake-fix.yml) Co-authored-by: Maksym Yakubych --- ...me_laptop_Design_System_Colors_Default.png | Bin 213633 -> 216690 bytes .../DataComplexityCards.module.css | 36 ++ .../DataComplexityCards.tsx | 407 ++++++++-------- .../DataComplexityCards.unit.spec.tsx | 71 ++- .../DataComplexitySection.unit.spec.tsx | 66 ++- .../DataComplexityCards.unit.spec.tsx.snap | 442 +++++++++++++----- .../audit_app/metabot-analytics/types.ts | 57 ++- .../ui/colors/constants/themes/dark.ts | 1 + .../ui/colors/constants/themes/light.ts | 1 + .../metabase/ui/colors/types/color-keys.ts | 1 + 10 files changed, 704 insertions(+), 378 deletions(-) create mode 100644 enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/DataComplexityCards.module.css diff --git a/.loki/reference/chrome_laptop_Design_System_Colors_Default.png b/.loki/reference/chrome_laptop_Design_System_Colors_Default.png index 21a104f276fd898b93cf5bbc67dc39557daf769e..092a5aab3879798ff674e637f028e7b60b902931 100644 GIT binary patch delta 183649 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≠Oo_o5fpg@t}#FM92Q;C0G3tKBATHF5|jA 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

iu6RDB0~J1MpE@w(!evC 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

Yri8?Hk4V0%#=Ba10 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

C9jU5x5vks^w+s4#0YX^7zxhHJ0%lP1t8t$@N zMZhuJhIX2V(xU^w0-r?dXeso~fQFpAK(Q_-_}Ru)YKx1Efbs>DznX(BX1AqwzO1cr z+vJb)C$2XRA@uuKaN;Tkm38azbZWvKkT#=9T_)mw9up#VzcU(oo7{2zuD7>D0cvFa z&m>=wa-`tkqbx$vU|8d*BDa(u9rt?I(KVr-Q#)X`^IT8L=jplw1<<4aO~XKkCe07| zDT^{()yKz@inv>fSs5{j9UX;^&wjb@tcSf^vw4zLYH<1aJ;=EH#~Kiv588%} z;GCq8;kXmTc-YYU3tYf(b;v&k1&~p5B2SF$4>v0u9D6}NaDQge#9YfvycCW6pTA@N zbEJ*ykccn_zx|=1W8yBDo6H(9J;9CY1@`IQu{8uJDXn#@mR zTYK=aW5a>Z#j|&7cV?yq+!o@y|DlG~1(DQPDq%3(@D2+kY+gV$dTsGXbuO_$6OT~& zq$2a1P5=FUwL;#m;5LS*(xYRGGh&LqR$e*EWxr{XsVJ3hqNwl?gMS>Ve;eo9`tL)E z!HLzvX50Wi@O@j}w0rTXiEH>N3pnqF!MA_oQ&PKoT&#@0V8RT-|H@4Mlb1Wz)zuKW zk@l_2CgN}Y?I8ruOzwub>9*FeoWXjr2+uR$iTW`r5{zKQ<;f9VPy`$LT~baMf|-p#4iggqlOFVb z*D5FjYLTL{vhv>pKrS!vUHQ$-A|hiQs&DdIzQbNeT!T*{2)Lym)_gKWBMjQPfe%?g zu0*&FBZd{gJN+xCcFOhdmK)fEEO~-|m?AYOBkAYkE=|BXxs>42`L&RqhlY07$D@^$ z{@9xaLyujSD0Vc-lKE@bBD7nN^*Ze@?1UxnulwsHe`s?*({oP2cj4G_znk+`3yL`KgjPQ7Sm7E^;|dFy(TRu*rf8S*oF8v;YVM+6dn0+>fj z!R$wNcI6;x+lC;t^NuD5vF2|c=_fM?$EyVIuu`}ekLw>c?f;TO(&r-3nmIT;!WzEU zOqM{nL{(y|?r*iewzN_(;&s)N_xFGbyUo_&R!6!IZks=>ja)A?pAS4++PnSj2`jq8 zh>6Rq>DpykJ=F*2i=c_6WpmrZ0ZW^zf&VS8R73@*lH8M%h5ZkS|DsGInYTg9tm;N@ zPxB<9qe|_vu7XPHROXyhIZGa0tkBNRjp>iBe}E+t5wfHB$%4cgmG4v@h(=sUHfz7@ zlQQGPcA>gX8we0f4_b_`X+>F?`NgDmaM8F|*DbC-6maQsf<^JO#ifRFRGjDe`0l5* z_0=H6pXY5%f1a`>+A1o$RsF zMTKb@nSe|yzF5d@BLNS8^ZR}9D`0g!1=_b2 z4bL4u#Yq|SS1Oxe5<8Q{daMG{T+#;z0@cgV0HaiVb-O+L(o1JGTWWt#q>|3rmhqIxqvN9RyGp^EJDv_h86rYvBtfu!uQZS(E}0?i8OR2N0& zwVEYXyq}3x&vx(mHE&P+vl{Hghi~9@CDKIpMD|_|MxLK$oTVSpCSoYX>SAkiMn;5X z2BYD1SRXbQuO(e%W@HE(Kao%_VP=fN8{Tx0DbV@W2z(_u#R8o#Qv$*m@`CdkS?&_F zcTTucG%%wmjpWNF zu<9R@?s?!7;81{H_A%XS-_x{d(kO+>A_NZ;kJJzl;5><-}Efc!WeVBUNiUHYKJ@Nm0iBc{O&6Q{9h(G>&hkyEJ@DI zMJvav2y1MN@t#Cxc1sF^VSRY{geY8aM7|K*HFd`r+q4Lek6;r9mQl+Mz9PZB&nRzM zKx$UR0)tOX1D+XH0gvYJa{h?y$~DT2u&#nHkqYp_1*3FuKiGR?_xL3JD4Yh8Va(Zw zpJf<$%HZAL*$CKI-vtN=&f!)q=Z{re1VCBLi($9llPb)Nf6fG_RY$4*+oR@&XAxK! z&vOoEv(T@>xQ=+j?CW&%{fpaU#()u>+f$fUFC<%>cQgvK!!Rd?)X&2k?{CIe4DHN6Xsk?A;SWm#*b;Gm`Lhd0_rS`!f?qYs zGT+Jj)+i#+l+ZaD7&|_Rs~$EJP7i|$L*&_#zRS$sBZv_{)PJn=_%TUQp}E0?J|kdG z7Q^YjHB!j)ORI5mKOE6P$uT3hbd&C0dl0kk9St=Pup$AzEVtVzF+BN(6OGe(VT z9(P^r%Hhi=rTY(Lfs{|h_o-J>7iG}3ZLwiJA;^iObrYp=XgW}YNh>IzltL{RHXClD z@HoZ!I#BbP0Jo;Bp`qmGCjo0SuP{Aa=>bx^+)xAJ6TtHT-5XqgFc2gz>T{2O~q z8-8(s*T15Zq6Io$@W>`Ij9SbC`)GYoNmrK?6AQ~3#3kCT#n1IMWMvU+?52XExf{^E zn`wbi&kK6VLRYJ3c(h-E=mtuvI4y>fna{WQHBVo{<%+AL!Em6_eNagR0YAFqLZ73x z{!~dnQNZ_9_?$W1)h~wubilPt)Yxkg<>5O)t_{oXM|tXr#&x`2wZDWC7*e^aN>P66UrH9N#{p*?NBUc{2STpUIN2B4*j=`@&(_L$t!7m(3!#k`(J1wOj^*g7EO@FDmKPnQv zLf#kV!9Vdhr@n?TY+UiSq4`&F*{_rNuhMbR)@)-mVjR6A1+T}qkm@P|s6;JzdA zk-V+b6Fm02+t@hA8PvCSE-o@{=4w?81Zm(|ijt7qg+5(Py^JNI(cf@Ds~Hh6&r&$*XqfJf;{ zVt?cWATpSAYXjm`gFEMY|BFBA@!h?>9UvhTGe(ifrS<;vfv@Qzu1=uWIUq;o0=kyl zzr|27r1XM3CtlB8Ll9NMhc0L4O)K=B08nCau+$0i4n1Ko+Bh^&f`k}YYDf)mD~q4r zIeY<0zz1_ad+lJ-gW3;uC*w~m+_!YAYHOF*-Z}2ND|qhBHS2?l5Cc-TzD?j+{VHn$ z8(Ui-04yGOX&M{H0y5{Eu9usWvjfcL3Y#$wxKT)82IU*7**G~tWe0+52ifYEJqdT} zPN!`TelN!{iHJZHu>Io^4GZun!{9QA{Ug-~rOo$%U(kM=Pz`S+JT=7Z*$SOmm0w(W z0rN$QWo?-WU3q0ktS&<2kcY&IyB(&T5s=hJojUVW6WYed%uA09`<|p}0=x}N7SLQ@;Z zKzei}$+_Kzg!`_Vz86!#+~!L*(IGPSN;6V6yRmNuFt;xs(BEOA3b=^t=(aqJ7o&$)^lO^RI zl&{}s;Aydhk2nO=N3hP64l~|iLZgkFnc%KR#7y>X>r&uPI#gu=i*lVIF0sbJZ0gd9 zmy_MCkn%vc%<#&KeQ$|;(I&bvr53q^(ak@%gFXS7U*~a_(;UtjZN{h^4e~y@dY@tV2%?d7%Xns#2NH=`@3*jVJM!uLcdE!7CLbW26#nbzRxKOyt z$i00!IbhSk!iY<(`dEJbK4AiK6$E?=ukUZ;*^}}`tH|8D@GP!gj4U41X;naE+V@>~ z%%kRY7tL`m1Bjzkx4ZRQjz2AWcCYYA7F>bPI=+ct;PrtKSkT*X8gAIxwVSDWU zjD&MvfOWxn$vXRn>uJ`==GAR1wFqL2BGSA5Fr!8fnFMA-=T4F#Ykr8~tntMN3wey9 z`01p@kWKZ_)=Ay!o2OvZz{%Crhb*9k0~6SJN_%^Y()t~1!38jDYwP{mRnRd=&cT5j zp1FEgKLC#1z{;JM6+yfczS7%NQ`XnlU!%^^dT%fQ>;)-^9uCCvyz3Ws+lX(y3Phvb z5HMMRK;@zE=zA^X8j|PCu!Y<>$)I8DuWCZG#QW*UM8*A;R{gSOW(>xDCy!xmu{2^% z+mpPYG!B;;1O-l0_3wp5T->j&ZQD9wEOJ7W*9rlXu__&rym5E3hwCk3(Nshjk&>y6 zt>R5cfy@@4XVp^M>EP8ZwICL<7HZkVawF8hfuO?v0{_|P^2?a2n6+44FL4J;GBqFt zgg@j~QngYa8qm&fXz2ail`KvC42=G3RMp6chADepuRHguE_Bdy(6w0Pv|T8{3I^KY zP98qW9=-$e?tu$r?Z)oZm=2~dSlz7)MRsr3Jw$x$5)t1T;>zG>4d=BjJ6}S*)fYN! zk8MDV&^-q=0nBWdFJ`r}hu*J~(owaD8KU@@trIRRi&q*SDdT57=-sZ!{JrkxV2}A)~GW_0fwxVwcIg@lAwPJq7s41VAa_aL<7nHP=h?oOWJq zr=zf-=Qsv@Eb0e-UqlXiIMk0>;FB2yYHS34WtP|;A1DV?i<$y>0S9%jAL$@!u>>1t z%R4x;zq03Y5}`5c)lks&=OtDpk=F_SQD^a27Ta8x0RgtOdj zQ`^$g5*aGO1YTWmkL9o0t4=bLua7h&Lhn3yvrseR(2*u!E4y3U&_IcTf})9<8c_TB zGvUjZFXMYP-Y0;LHPDl+ls+KuPVTgHITjWcYHDi2LS+e#0q;2(=)N^>1~Me9?d?%u zx2Lf9)XCNnZJ;t!IW`gCNnP>0_dQgB16`0NN3zVIim=2CHVppJKye`_P_1Tp5W2Y! z=feSj8G3z0d=!dd~RlZ$-Mf)A2o8U)Kz8mS2i#<#|I*+~Kol ztLaBk1X{@Aw0aY;#h!a>h<-BS7rPQP z@-y?C$jSu1;R7RqVyj??5(8}H^JdrRhOj^V8j@5_2#I|Deq9RE1=H^Bxq`6&!c*l0 zGF5j*unp`dL!mLA7^sx%z#O%43FzODj%Z-c(aV zx;0Uz^zx;MZkcfm;9o4jjsh8;=&uMn{~K_J$v@-4lD~Ou69pB&6y$4vbySOV>lOHwq(4_Bn}KF1|}w^6=|#5 zmq%;aA3h+1y-vsJ&WweB?IM7E_utK5p@M2ConUkY+RxM=px<6%S!J~nG5*qc8;|}` zlpt}Sp2xJl*G$RQ59c%HgCXrlkx6}q`9uN`(zKF`H)+C1<$~L!v+pf;J|Vx^8PyA_ zm=ZN75$cwr?*wkCvSVX&I%LP{6pT47mV7AUv_i}(htP}d_wx@}62rOTlC6Jjw-9Nu z=(X**9eqY_M^P!V70n^n~+ABP2XzC z2Ydz@-mNU7e^`Q2NYi|eEc17-k-AT}q|NXOeEeww2S9aDln(PFX2ObstNk68hhk)r zDi;yc#Jj%kO6ujFDj`gIY*R{~wC-4SY#Wr2cE!7s18ZLDkK(%pGdQqwka8wci zxJv2LIvYnJ1xk&SPucO1V|#5^beZx~I*&cplPMZA?~Eb=k6YW?c6@%;>(nnWK`$h9 zMMVKH*67nyk0y}kvG3hH+2~nz>-?haOqRUbAO^evEjv5A7bwEV*ir;aePBaWf_829 z{131;PF{zaeE>T(c(%+RZ2fY((ivpNAW3&0kw4@Ig9#BN!>4;ti93){3))U(L=mRh zaxR{D)2kSR&6g=J1-%NPO)Ranixn>4QS|GCjPU7PGh-im*3f{yPjg z6)N}&UO%}(kfqKD3{pY{bVkvb9bg^2BxqA{>^1K&U(lA|JfiK9406`z+CR;IRG^M- zkOxscoe(s}>!Y#P%pV2g2P``+N?Anw_zG`tc_m?*P$3aR1Y%#9;5@IF#0J0nu}?vq zpa_X-^>pSLCvw_=oWYf$0Am@*RTofylC!%X>hA>fNeqbS zm;>*S;jC!PrULk*+f$WjV5v>zwLpEP$OI}A@l)bR-u$-Mo^2!pc^bl7#g*~z-bJSv zd$Z>(4-O7?_1#P8dS_cxZvhjX+67{AU(c^y`~%K!v~a?ST9_Pu`IbWwwmkC{w~@-Z*>V<{m# z_G7SB82_H^#=(P;l8TUD1xyDn_p$OPefSCl1#d;1L^7;P%VcdWK#dBK;}>sX_ycO#*(A z?)qW`1*}v(oV?4zKk;5VuO~p>eueDEGZzuCo|y8wXMqq5X(9rpk;`u!w8_6y0v*w` z(k-XHl+Zq=TGO@)(qy#(Q(2qOb-`tN*ibJN#Gv%WV(=Ast$=Ay)vj~_yZim?iOk&Q z0$OnAhz5kwYSi!JZeRTgqF{cc3{CVEDhq+fYTCg99^N_|yXWbTtHq+U_+jCm2Qmyj zKvXL_*T-Y2k>(anCBgSzUEtoASGt)|;ILRZsIf*SspkrUz`2(-q-H#^#cuFGh-c$h z?rBRrGdkk8VyHk# z@H#7_GEd07e-B40Zc=$z-wzx3cO4fWSwF?<))}cY=RE^g23$omJKHcm1_T2maB3xf z7T*IX^Bc1i)UYwXkG3Ff(%S_kmrd2?zEfV{~eP%Y93t zx591bJNe**;U4zE6I-FO1+oSnw0yt$@wED!oLo{LsHvSmtyAydFi=<~IQGCnR)5yD z90)3`&yB=|=G4$107TGm^TmM!>Tv$IvjL)i7d|rl@4`n*03`Yc2$Clz`yZmfe-}RL z{I9}C5o~`Hi+}S+&hYBbl$FRb8z-&7)i?yh)^O?Luks=VS6==px%B6q^B?3F{UL{l zss9mg7~uzkYMR=Kf^Q#7H@yop9nLY6Mf~lA^&y0~Db7v>;>7 z;$1EQ5&-75v7bt1z_ry4CJquz;fw*V+TaN5Zs81dHj*ggUdB!4Q=hM>($0%@`hq~A z(ub%__;LM~`O>bEm@$Z0)gzvPFD|V5$B)Q(=7NdO)`vv#nuhxL9iZMzXJ&qU@1jfn z(kv*X#IWpEpB3MW^v2y2h63)HE4_*VmrG?OYTj$0u4x*p3%X5z2zZ9nqv5Abh?iMAVl#={v^dH(b(kIhz#A0Y?V zyP|ufYW^%Qs#6UigD8O_y>C^37V79(w*Agq@5Y!=HJu6ySLC*bEScZEjB7NX2K12^ z!ClfK>40u z=wjK2c&g#@O965EmDPP?{~fMT0sbZV#{zKilkptXLrOH8cu@%g`0bl6?OP|`N}T28 z^JF2GC{V&qvwL-jnFVpf) zFP57jY!UEz5kc(a#P?(HHGDw-kfwzoj1v+4_3}MIiIh;P^jF8OAD+p}SA6|v?*D(B zy>(nwTNgIE0R@#35fmhp4gu+AgOqf4DM)vhuuzl~5D<`(mM#Hl5RvX~5b2F{vxz&m zo^#&w-tWHOA9w%!L)PAFuDRx#Ys@jn^NeROc+GRD1JZgR`HpiF=3pEp=-j0u$P9wBY0GHLie&p6{QEccAZ6=z( zjBP>Wxza3ws{pwl=#PnE@Os8s?(J|ZNT?sMmbj*0Cu$4zuRJx4biqRw0SGuf5=A%s z1A6XznP&8u;h$)`r}n*kv>jxT^STys%Ocq!1cgP`+s#l+5e(R=IPVum4HN-xFCy1Z@h%) zhIiH0?z)9?p`?(w$WYv=-?(syVGd3Vmab!}mH{S83aNCMGZAx8SXGi>kTO+>VKG1b%XV@3C>o#<)shTi z{_uobJ@;;VhqNRT)Q+K)km~-`n^YWVgO_h23?+s1gt+7Nl5iK+?w9Wl@$IM~fBVmn zyS$nM1^}ip4pR=MH zmJZEtHjzj0Vu&krB=B++_lwWmQj-&FKZ<=7cI7`$ffwJsGsZa63hRW74;Tv3A#s_WE+KV%4-=6D9s%eB_FJ2CU_-F86h^{pr|C=e$ZglsIvusNb{niMou6x~M~<`z{hQZot0n zcB=l+PirD@Cz3f&ISJ|r$YIc|vU#(F znwgq<7E}Qsy&cxW@0*P zji1$kzFXR@(eSObqG|{pP)JKfDq?fhI!!?sSJy`je7_F4eVZ?TKwk(o#s*nr5}?hI zNAm>wV?;}E<(ZnB9q;zqZD|p2kO>p#6S?8Z-x^?uOH95RICMR~XSDd?=^0nkWbaF` z=+E4;+VsylhZXS1fmRWa6VN)6A{bzn0UXQr-X{_6dO=VSrh%Io<|J-F5IrG2cT-i{ z`kMh8DOSKTvcPHY>68c@xa%M?W3fB*luOK@3)SlRF!xbf^Uc~bt_1mZRH2kGs`TnI z!VUq0I>21udakk10`spL{Sqs&BNC&A^$5ObqSpA~jq`~<5eea2nqh{rAtn<>lb zovw@o*BJwmET74{6xQ179LNDND_Std)RTBk&v?=JMlP}mnXVdniA!XJt^zk~117Zfcq zq>*`b|Jp@LbKyM%Cm`1RF)$E{Spzq8dXUb}qD`+9mH7?~mC1h?IkPDG$uR9Ab-=q%f>0i(iV_ey;~M?Ppa=wmqg-Z^0y`e%>x2Yhn*Y4Xr>LH&;LjvCe007j zp?)H98^t&Wq#p=yjMh=tc>U6-nvRMJRaWE=tw-cHgNG#nk5VvLLITy21?oaxsFwcz z{B~$9y4SsF1%|&b)LAJ=xZL{FF8F!$@2+Fbg@CJ}I6=Rg<%&gVZ36cdh;@Qyo1lL> z|MTDNUI!E$i*7^%sqnk}!2ug_39iY1j4UGJzr1aBP_iUHzd6yqfg~RRgZ)i_aG?a= zXjWDh0~;GW>-_uY4Sz?9pJ{bh)l$4b8VN_5bL6u%QZ*+Nj#}GO{crYVfu*(U> z+ERw>iAvmN<{)__qV=*sT?cHyz!U%gQ1kce3xIAy1n4#`?}appG&D3?^74X?0Kom5 z8B;equX=|d40orquP2eW?a!YBPoul8oD~WQ&i34=N@tf~jWgMsyW|Cbg;al-=05?| z4oFdA$RPHQ1%%=u6d(XYp<3N3a7v&AkJ5=TaG$xFF|azvs_#jITt%+Bqo^ykQvnlY z<^ZxLLBZ}XF|aVF4}rY7zR$r(|C$Q}3rovh9~>X%0fcTMLF5I*mSKJ^#!ZVI(Llu_ zAl3EiXXBO|K+J-go*oPP4y%N-vvd2o8x&{YI-&s@xC{3c0$3qt_7KTHwfvJOU%?*^ z1Cn>d07~{Vlub%f@=fCzDE0Ay-sczB-f5)EC@11GjiM_Twe<5-(;ZCI_I8 z^B9CfzrDVG@%91?Z^-cQu;Gc0u5JkY@}y5}or>zla+@ix_FkOZteAU8zY*E|NI9p|{_+zNJ@Iyr=UNofp=9~!c zau<4G+cI*g5)rk|ypw@MIHvb5&DkxpS?vr;sTF%YF1(BjqwChz_y1!c1`;__t#f=jiY~GiUf6sB#Rp&GX#&6$qSLXd2?RVld z)XCcFtnFU&WL=Xa>}$oqznHzOFQ?BKcwN?8j}uXFN)7uFzhe0PHRFm{@_T%-cr>y2 zmv5&#V6Ye-{KQI|miU#ko4GoKrQUtMu&<>i^bRvhcH&Z-&u_nl6S~i;VP*_0+6Sg~ zi<+23Lr{tjWtXPqUdd91l4Dbg78PT~wL1o0+)Pa2$S!;ep;y>!)i);JQ+0pf$M-(M z+Crj#HFYj-v)3v(gl5YUyW>xZX~6Fj!}^XsVqL2J$h?;fFt2b~SXhpM?tY2ehVF~q4tf-V z{mA>!2Bdrn!~B-I;!HZSqrpF(TnR3NfQziRlo}X!m5}(eWC}t4~e@IxUtPYdeTi!PHqGJsUN}g zAHM>DZs-;0Hxan+CX~^Ep@e2Yq2^ac7rSu;l|X8EDuQ+NM>AW&Mc>wW8DX#$T4Liq z%laeF?Ipu|Mb+9Eu;a}t!X@{t`El~`>_&AAO zY{{r$UYOAE*nqA9%D8FiMWQJ*7yk4yR0vgd2G7Cg!s947YxHg7(BQ{J*^!`7#U}ov$`sKP>dRm~$c!j8V&9n9w!ItLoZ}ex8Y4X`a!6(>uZPhDD!GXTDx91=Tx_bmK zL8q)czwv8X_r-RvQQ8pgv%awuwCw|Nts-A^J)QJqYT*AjM#eQgUr;W!^IdW>EVTDv;cJxdoJj z#HQ^2i#h`RFYd^OtGkKO%fs9O3m}Y90^UmiU@;d}p_7KuE&B4>PF?}i8zrm)&@aHI ztekejmQZa00P|xsi^2M9=qvlfOi54M?Egoq~_(l?bkF^O^C{;o%LKHw;3;)e?KWFVKO$tN$6P6`-ae!l`rYm z#ZCoo7Zm#{%;xok5{v zd2TMGyGtnid1z6Y@v6Rea4V!``lx9%uIt4e^9{x8b`e)<)n3WdIrKuZR3Q2*w{LQ3WF!F04NEB=fmcp(pa#zZtOjwqQ-Md1{!~ zT*%Aj$MBI%7hrwbFWMT#3pM8_;3u02@4J@3c|5h`c9r+sYRNg`3&y5{ySgsw$5z4; zMP7D1Z|w|>b3cGQhqjLsh&(R+dn_zgeK-{ujxO7h!4nJsPY@Sgk2kk@C`@~Ngbxqs zF>A}4gO48w^WBT!;{sHnpY;r9cR`Ki|y>{>STe<^Iw-EL?lnB8+Y)v zeXC9g<&!x;Y%v_)lfs|exrzsW|1LHEmPE5y_i_^SEB_CxqP{5gHWATD*SDAygdAy& zsX1~P-3pjhRJJ}x{5Ko4;jyudj0}v9j*bgc!0H2JMws95usSI0bcI>7;47dHf|?Tq zZ4uE6}UN6Lk9}YHiI7JFdj?`~LmA?-ZzOG2eR6{(Cb%{P%PO5?uCQ4+&N5 zo4Mh&#k&E6$tK|aYAmq#1@kD`V>K-h2nrB`l%$}?9r7^?SZW%_9)UzH)i>l5bH2Ue z*Ddf$BQM0n7n!tcg1GOb{7QM@-5$O&%RWCPj#o}140~Zey&0Q&1L`qMyXj@i`R+h& zm#7MfDQtIQORaC0%BK~M+t~A(VK?{|RY+rZHx3}%1O(8@!`E?sjaOq^kHJHW-8LUv z=WpQw06IJmp?iCA<;|I4<@PA#Kef?%*vE@R=4WNmdi=P6yd6d=c$?N-j33Le&z{a* zu5stYfB4X!|FN^vYH?|Ke{+wR__#AKFEa8Y&yNLlrrRY`K6k?M@~o8KrQF%~-mYUd z%P7nEJhzL@c8qp$G2iw zkI(>F8%NtvPcQUfeJlg)CIGV7`m-gJ-E;TrvJFZv4aG%6AgoZ!9oUNCR?mS2@}8@! zt3$Q^`8Uq#YCL*SaS6B!VqFmw!XXn-nYjs2fCySrhUz)PiEuG<}xzZ>Eu87H3#BjJuEIPY%#m}Bl2%D0jMoj;&osJ@(dPM zfa~dp+FDL=b8?S=o(ZYL;V-I}@Rz6vqWqW`)OgwT3i7pX#;Am{VX*MA>g{MO(o#e( zS3wOYuB8`2wU#EHrQQt1b;zqZ$ep6EX<>sk+p0M~0os5{f2Eb)|iz+gMA}W)+Prpn#|7Ctr%}-^y0JcTH-O42=u|iG24$b`ZDPY?EC}R#^ zsrBh{>xh;SZlPvqAca@^C9i>|=JI7Co)(IQ;0b74=O-s% zHIqnAu^uhB{vieBtD&TBuqa>LYy@I_r4|EFI@%K0nk#Ab4L?14hIMgLh^u9|S}V6Z zDaUoZ#S20}?=oDHwt7Dj9lAn+;-iWA{7$+0Jm?7EU9tn=ngqou@7MGV6! zprf$lHftd_=6UI;NUv;N`e36RqDOZffg0Zj&w-N<99g%C+QPvi@$AA?>j5YQ0|6iX z&gD@fGixIbsRC8(2dT$+^~zD+J&IL+nT&Sa$ySi0YHb;@xs%5VK<*x zG+ICCiUC@$&`RL(S!nUAFIx!a718{WA8B*NYlyus88#3+*6wS-E{r2r~lIy4rSk7AH*y^vq{_vt%g+ZaF2_OOdK~ZK;sQxH&AwW2f&HnJE30?s|4CGURKS#h9D4xa!4YU@rB_wm zHBWNHjEahKXu5hwLj8y~P9GhJ(HWg1vh1%X~@4sn@XPNCWp2 z)#leK--=J-QBlEwdy<_3@qiIB44(eR`NOSt=Wv}39cI>7b0rQ$g$?y41ZUE) zuzuLVfHj16puF)J??YmRhpWU_j!8?XoYW zWWGf!GD+o?^yTsG&3a^bkkQwKl$6lMizUmm`OGW>4O99`2%|*-G zy*^2+ye+)*{Gud`vT8#3c}ItIjd*ff+U|ZC27F6m=e~MU&ijxyqVKij>>27zD2~hJ znb~b42r&7?eSCbz(Tvm6+|p|>b=KCvz(moU2tN3O`?n%KrX=d>si@wrZOd6u0P!Sn z3J3?@SkpGukl$7FUi_Sjyc2cu|}b={e7H{vr8Ddl@me1e`cvY`&kwXqOZa#3d&N)e<{Q-UH!j&9g5! zU{v`Qe#xaOQASMc`M&jx>w7$Le)PT8GJ~{FFzmMf;o~F^M}g!WI|$uX2G9UdEP#N< z&1%ZvAcn&zE~?QB0cunLEd)E6^*WnSqsVN}U1yJ3%gi6i|9_GGzX<}rf&RZS_YesC z|G@TJABx@dI^TL~`+tjiqBsVKTL>$zVs()5{7;hR@38Rs{;zAF)j-sh|G%TIt#u6` zaWq-njW{?+@vnqTP=DIx*1z)|StV3%&mVgH1+pJ{+z)B6<{Z!u-ke3HJ(`3~ZG_AC z-iX35)RC2+k^-o*r)lm@8Z1Ax4d{l0cOAPeC}mU^Ec*ssF`ILC<31HBhhjFN?3?tl z&rbw`ea2MUmmCC8@QfQ@k|M+6E@ar1-l0@2|4NIS{ZI704iT)}o(T?bLN%xeaz^O? z3-&+tsoq>}%$Ej(L>2@R%%f5L3hG*VC4_mBt4`OH-%Xh$b&CL3oI(=qHj}~E&o2+) zL=H9lf7+6fm#A&J`#YUB9?zcuO8EXAt&ym7>vR%u09;-Eg8IMUVU(A)D-}}ozOBW~ z3IpgxB=drz1$WK(K0K_BPZ9L$8VtZwq682kLP8#z?zp93Il zaW&`eNoff#{SjVnv6S~*KiEcJjvg#!6Ca+@8&3kOHs2oG2oZZF(*>NwzYgH>mY)L8 zOey{THD$&a<@{l?RGM^^!qTj(*R8lgVWqEl&vF!vOAZK!tr&=I{K5SDhjC&a2VbcW za65@lm3+J_G49l5fqq>va$1Z&E?ygQ(E&=3BXyff6Ad92#S`_$e?w3qhNfn zrIRWn$z|+NW_quji+Yq!)iZW{HM?c?%`hKT)y9_{<4f6BrJ_kfVpr}vYl^#&y11-@R*g*r^hlZif!2${<)W^H_Fw|?YTQ&0KxGKzZc8#OIstaP#jRUj^_ggQ zGtYkg@*5hs9;fx1CI@q^Jtzn>?mmAibW>P4$EDvYI*<--J4$ih_Z1@%JZ+t?O&w3* z(z*aym&5Qpqa9v(n%vwlwXaW{=REG7sGLI0LQNGy`}g}LF@PPH_G-i``tcj>)PCc4 zfVx3whnMn1FnC@da3%%goUG{KILLDM_$LkecD-S|MaAS9Zp zJt^;l8l>}_5O(=PO|3#|MtZ6cqJLc`F>MA^3R-;GdY?Ldo^Ja4&zB-t%%Pc}Q^Md^ zVe0=NKIvo3_iTB*+uLd|eDGes1j2T-(;9K2>#vd1?mBb9?cS{GC>{_*ef=N6X7x?I z^PpLo^uD%x6`%*~5QF+!4pWCj3RocJ1#})&SnV%Bf0JwKu#Ls_tNh{8Anf+MKv+5hp2w6j2?|ahc=ewidG+vLm!L3?qG5-&gBUii zhy@)FytkN59anoe`*yq@g^m40iJbgJctAI`Xl&}ROL_#-i9bJ~d$^?|@~)CLpi2TZ zlOVOsNcAkJ*r-j6qksm_`iTKFlCOld5xeTfKuYp(}2hP8@vCV zhQE3>$@#NjJJofr0Kg)UqtPm;9lKs`SF(X*#gBD<8Ow>HSD-{p{(2fuNf@?jzXDh{ z5!Zp=*1?m;gLWd7Hyp4hf?i?Wh=;x5KI^h<6Koga5T5fun9lmS^zuSe_x$Dnmw z&o@axDqy|0M52PU!|VLrVm3f?fse{sDfo+A47>~|w7-1`&}8~8E5zec?05kc0F01S zrlGr{Zb9;+1oAVBfNF%R%+N&KPOQ0rb2h#q555P z3<>JomcVPrn;**c5PXlo;-wo~PIA~Qplv(qYdX+704koRrbVKF{F;qpGhWgJDB)RY zD=Uh%WW$3(@>gREhJdDKv(@^g#LwKt>}1q$LxL!Z??^>oj~|Tns02`Czo|@O|1&Pk zqzL-{=Recv?jCv69|I{2cNg`M+14F%S-Sdn=&!@CTv54sGc)MMjUb}izvA*6V)MuH zsnQwT2yS8@zDhT~T9BHm3jH!h6BA{W-MXF`7ccK~>4#$(Egx__diT6wDN1dnl3u58 za$+UP$7y$We@YgU4vp(YW05w|{+WD`$kKQY764zrf2&m)BX4nP zY~B^W8hUzOB}tydOzJMB?B)k$9r|(_=%O{`kbrNK$7(|ImNEBn{Mu08wTY>R6WN{9oY2{;<$@53u#r_ zYjnZ-KfFtU-MqU?2xIyg#qB56qeb7hu%lj$3nLW-2hqiS(M5G|6w$vv&~QCny@~E; zzeo?e5^#0}hDnch|Ht(XwA4~+INJS3;4oS14;nwIuDRegj2TK8DbwHQOM>%786n!_ zhC%0xeu680h{!+J6g6r4K?BQK)+$1SVf}RshG*<>OEiP{NnnH`6%5rm2GC9?I0*aw z5qbphv-J~mi;(3T`gOJP>*j&33 z>>AF=br|T;pXRO+fcxOmrPa!?cG3`dX97!2d+^uosDB5Y`~C5IcMZhuoQ~SK><0p# zCKrwNd6&!kUU(-vP82A|&8s<+h}QN5!m^@nE(#V zt#o&E44B3lW9Tc>!@;YY^{^v14Xs)zsBZpi@X#G^hk9o(~Xjkj@u^ z;rj6pU%g*0PnR(`Nt@?6G_D+n&Ls;;WvDPJBr=MKG;Nv0`c^2PczP!6kE`8?2+C;z zbOzkWj_0a@c;v%%p3aRCp3CvsF_EQ4AP%H1FAsLaSg9iH@$!iA(hNYvtzU1ryzuq` z@hVbbFPCe~7|JP+_$J!pd!&3WT8YwFF)YBHsBW*`I~)Qjz{Mi|hN!6hv*QU+e<$~~{FWlDnc z_I--(^^}bMc&-?=4|NJM4}11Ihpe6Dd<*x~e%2;bFdY5y9gU>BueyUtF?-np@#1>1cO)hZ7FgG$3Ddb3+liBlt;}`$?$% zg^K)&bcMIkNB5a+_N~>@INyRp@BNWwgq6re_Z_Z<86w}x(!8yD4t&K+P1{qEtl0XL z;iXHYG6~j=`njWOEy}ZMcZ&TaL!T}8Eq0A_MfC$toYuaaPjfphyuBkamA>lBWSlDn z!@=hD-!n(HyCCYLxkAcD!6Xb5`x?dd3;lfhY{fzH%UTsB;lrFmbFwpGAK%jj-CD1h zSk_b3g^MG~9;8JPjU^-%U{<$Xu-R}l988pOix;0zhH0(|46zM9-9o#1G#AHAT3520_T%* zJP+crpZzt{0Fbg|Fvn#TY>R-L@p>P8OcPmufEZB&l#~5+PS&a)FIe=el>0=G1*qEq zA}RS_3}A>NF@MZ8pxto5$_Bm#Hvh1P?uwAr*8hpa5lRV zh%2rRyBkWQt69>0BMn3gBbDaN1{aMHi!L>^GWla0L!9^@vwl?&?r&F#ogQtZ zt5Uyuj}8&7#ipTeQ>FRBx1OeB$!UFHrEzS<-mvJchI^j$m^zUTIjP&y6e21%odrId zx0GT~yix1q(sS(TTR8xJUd;=QOy7~Vo(mCtA9XitCxSJKhKP8A`&VbBtLzrfolA!>&?qj@6*R z`f|@m!4sj=@*QXrRhQAq@J5gq7ty^)0K%F5IrOz7oNg?r51pGo`s&Y#iXw_?|t7ig!loD#% z0i5OlfC-i$=j#;egY33z!&u|wK;8K$6by{&YDb4m&B{k_8X1slCa_iKJkkikQOCjC zSp;Art21HE0}>D}9xHifzBXxHJTzO6+~01FxRPZ6du%eCqXN=Dn|ZZRS_vWH;iL2^ zWJz5@%*=9gFZRNu!bq29%mFLp6x_cn^y4d9R%Yhv;S@8pYks=FR*ZGM0ax;LMhp(7zZBkj`WbpHkDW*=TfxrfyC&~aAqHr(j6 zvVbR>qQU1K)wdq2frpynm=p`bAj4^khjIRxarW|JHX%3C+;w11kFx@-T_K^#r@OTZ zUeBlR`@FFD>NL-Lxms0z+tnsTed%a5zh`7be~3r(pk8aVVYU5{jxAoHeytWogtWNQ zM(Qd!w2g&LC*&>Sbu6UhBp_{c2@0HVWXMYTm3Zi zA#td&u?e^FaEmUojcI9lk*p;T?Z@gpV9#_btY|}9(rY&pQb9iUT5;n=6wM5vS|Da> zBf#@OY1n}39P3++(B&vVF92kiEfY=0NJl5Nv$IpP7$s-QMX^a*J7YKFG8A?LKcas~ z2~I7;k*I)$1nRpivxxuinJI9rZj zl4)95Id4IUJm+c)eL3B zZwo_Y>n1vXwp1;DE9HM^di#k%*4)K-mbu}jMq|-r7RCe?C3iWIV1$tja{LPt;~rLt}5_vd;uLtE)v~4nNylR?Zv}A`;DIp(*#J9Mioj z4tLWp)-gY3Vw3I~EV$`Dq?-uAH-wVsN(HqF)fR3OYkKMhB*%VAf^QX-G#K@b>TVy* zA^Le#gd>zjV(z$&%?!m_L1t+pdUYyy@V0)=jE()*G@NWWlu0-_zUsBvGv~7Kz0Y;2 zeKWynjASw~viO&N;;mYRgH6QF6LTC3qopkgS9o(LrkV9F#2JN-3~!rzBpNq+mPS#v z;UbJ|Ev`A4^T|6)#n<#_sIjHl?1!mZ~Vz0ruFCbx7{^G>R)XYp);Mi;e*t;B%T=-f0p-7*G6V@`INz}Wmf68(l zF07pWq>=%#Mm5j(v$v6DRL*+UcBh&>f36txxsy`-a)k^d^K6)@^3>Fku(*S@u@S&-6e;r7kGE;FQ*yK zKU>AI)}M&8AQyGHm4YLO)7FX0CLtxp-q$e%a`Rc1Gu& z$X{HUna|;y%YsKmrux|I=e0}l?`W^d=9r9lCZQTJQy3i)EvKDU>if8CFg9K zYbPUorC$2BH7m-g1@b7k91YV#r=}yU^+&%XM<_6^T$yfv_BWY8&RF%cle5#JQ*|B2 z`}lY)JX^UNrj{@Vb=$cH1F%E`vof^YljzdRzhW_2-gYorWep=C^$a@|eJKDB$oz^gs|($8`f;xiXBDTNbQ-YM@Y@E8yAy60U4> z0PYYF&8h$;GK6=ZY+v;L>QD0@92_inTxdnOWA-y#Jt*Dmi?vY~OEcj2+GzsT26{_ zsuk9)_f45amY-hlkp4I#*`>8L`jhgD#rOW+s7#d=vg|ga6B%GUMrc_iqkxwd5u4z< z1)V3S@u!?K1~z8}Ig|+j!G#C1;+SslrWOXu<9y#2jx%Nt6}G@_M*AAK3ZPy0^qQ1s zu6alj@(y>Q&C?4n+e29cXGi>&wjE+qGGPPYRc&uyV@bLU{^eGC1N+AQTA;J*i^|Oq zC5u~#U0A9n3)olyg_=&VDgGXOlmlvz2mmRk3eP=aNePKnK%>)m{P>kwZ_-+Q^A5`8 zS66>e;qe1ar%-cgaS^frJ_nKE@@~I4zV(l0pdhV2jSU0jl{L;71IB)sV|+@rleJ zYViOBLwNviYsRq}0je!m8Zs^~=Lwd`y@VoZ}EJYP@fG*Z6`!+q2AwBNDKb%4YiXSB4<4GiockWXNi z5jfu;;(q!S=DIPd;d8pLVq^0bOf2fPAC$d-j1deTdm$*MYX$1+0#1Pjh@HIIYXk}< zvXG+Uz`>YNww6(H+T!AKVATwehgsIhLvjSjEnNS5}o`jEH2$>4YAzdJi z_SUX;wWfwSP5nX~V^MY+&f$a*@(ZRK4x)20sU>_u`|NupC@5p8}*s%~tFXn34rvIFOFa z4CPP>Gc-3^G9+LzeQ+*rpzGgyWLRB5mT#C+rdnw<$2Ob=gZb%lJo|Pf>(i$^`?+Y4 zog)WQGO7jkx;b6f3oGB$aDiWV<&PQ$sZISfZsuMn-4i#E%4i1de1HAK6wuj*O;K%t zigMX^3_aLUf5EsI4i0-|{sa*r*ugyz$+~o~1CvA?N}E%GCTc)zSK7YeCMWRujEfY6}MV;sx4VZPgJWm&s|t+-i5 z7WeY1Ysg6m(pDKZg|PZPB%~!FCZ^r?J1T-fCMAC{dVBra5;*>2CEu+VUK}$ywp}{I~CW51~VKC)8uAA>ajjyg! z_x622p$SAIzmF2>7KQ|e+>rG(Gcz9pM{Y7n`8TB=k87LW4G4s4h+#RdYXS2i5n;KG zZ6%+5i*>#&d1vqRzTIy0EQK*I8Ozx+)Am`ec5kLJD?` zHlyjO@^gvz-!fEV(m2Hp@L|IVIL$XDC9;_~sPYE!o@pdY7$K!4rSdGDqP*PT4{|Ed zMGXemzuQ^GMnUO0?d_>c=FcTwQ#Ch}q+aPy1+6o325Qz0C$!Xukcn>4R5r6h9 zQaVbABBJ06)zb0`b5of<@lt2HeEf4MInq9quG!bc!+ z|Ezq{rW1fVF3&T&&C0+(`6a^|4A{Y&u8mmm$h($Q#Z)4}g_v>VF$Lj-i8SrE%v3z8 zB+a53g9_37GMWYX7|C(?BHA*(eG}p*)8yBWni8)<%Sm~Q8TUZ(vy{I|B*A=$;68(~?TUHY2vhA0 zYxWRhWdyt%EwzY~6o&&$6zzv0A}%T`7K_=<^Y5D1-cM~5WyPY2Zsb1V)6sS9y0F9W zdz#m=z^1{1M3BY8Kke7#2?Q=hxb702G|NVx2)rd2Ya%vHhF z`S^HBrl81K`!ncc{?xKHIqdbVzuw^c4yGKXIqisK=g;K{`mQ_Xg0L%cuJ*h*`v}(# zrxT%k8-o&1zN3sB_Ug@FulLO=E6(!g4ywEG{%af%>H>tJT3>!yC&H7PcaZ=#K`{S~ z(fsIqo-tyTzgxQ58OtpG1C38NuNI*?Au_@eXQbszwL7>j3t*mM`v3IadnI$kYHz)C z6ETV*nse3Zpr*#83p>+ad&UcPjr*T=Wu5xL6B@DEg{Bd7Ggtj4)khZCH45QT;m@u^ zWl?8Wl<)kF-=J%01SBv|m44DPup%u0LzPvQOGhpVjcUbuH_)*hRX`f+9ZIk?34lpG zMIkx*Cpg?%rNw>Q`Bs!L(BpM&Poa-YM?2ZgtJWM&M(c8rn`?@sFZwki&>iSEmi6O> zS!$2wuSlB8DTAf!*VthPlCH^883Wo2Ci$JByM&_RV*j)r&reT#Dc-%XbJGL%xcBL! z<8uqy`}WEYS?=0PJ)8hG^rqE+h9fqO2$Np!(KA-R)9#e(U2^ut9aFdRyCgu9K2oSA zAykjwZh3d1r4l@S?C=MNLYd~&hZO6W%Edd{xW9q5--h{Sl`P1tf5QhOC8YzDCzP%J z4bq`&ttCa%iY+Up5_rKLx1=zS(jwKr6c;aa6(7Yo#}v|oXN2`nr%pOWoll7DBBW1F zt};Pirj}gzud~4pVw;}6n|L1Ea_iIa__(Z~Ps|&WwFB<8yEVcTOLuO!Z#s0Yxu| zk4O|g`B`7}Q+BG=cM>3X;wNWfYA+l)d-xgn+|e2PZ!tbkZG4^x0h^aI5enGjCanwL z4M|G8W(LN*CozkG{Q`mSK7^(4;^2GiQvJkAdQo2 z`*@1WD=W;6IYEk)ewZvgbW)D&>_nqTulKDf(l$3jLO`($qA42wpcnw^MyB|PqRc^X z-Ua@zghg{V#c+xa8eS0OJ~pJ%iFh3Px|NV%-8|0VtS7{}bUL>+-FXdugkmMDz_VrYDvr{ zdNFk=8>vieKUZ2Cn|IZ(#$@WyptHO85NXOYvVxh#x&wQp^N400gJ_-cz&2|WC(QFeRZ(Lfe7W%+{lLB2nURd?(g>| zt_ABt;t_VHghjF?6-2%#c>bDqKY{7l`3m=9{gfig} z>Q$!#{#NSyC`F?+erWX?y6C}QfOKjp3cFU{V;+O~`Tq@4W3dCITLn9_S(=^zhB|l~ zv||bm{>w4!UyW(JWiax6LB9giulesY15rO(+SZi#kCowq%>IJ$udLDhH;n(Mx!2cF z6okU4`~mK2Ci3?GPXEU?bmyg1+VBxV}a%FV4C+c)B zljuK*6YTF%6rSd*>e*sI%9REJRlJZU=gzVqKgVijazuJ#4|WC1{Wo~;+MWUx4dBml zf?MUTFqtC8q<`rZ)P!=YQ}HHJA_)+;V5x?kJso(i*J0>S@u1}n0BE@MXk3nV&Ous$ zxz1&|d)sw=)*!0f2kEBpkxM3D^MX~*qY8yW?6}WnjaTu>3_7;C4ayaF_b)^Uf2#LB zS}k;+QyVKX02?hjpvROvS@q0cz*RsqmqoSf#qy!;)PBD|zD3LKlB}Z4fa{o1aTjZ2 zdZ#mR!=N&-!wREt4EH_vjGrVQ5>tX-MtL0~0Fqf_T1GeP8t98WFqeESZ85&>>~cvR zO+-n_CSw(QGs}JXMM2!Qmr^6dP}ty88xk|*a-LvO|Q-ienn;`w>KV|@a*(nw}*g!M81 zif4qU`zCz?&j~@H(NT5{#he0L!)Zu`q;>a&-_cnvGL0!yg;c4A*FgnM8-V`{kQoV~_&+5-gYRHMqb_n^N0wKht`*O%u`d}L9XRmUFng2?6{mnE=a#;Yfu zURyn5<<4z9+%ocm)znhClKG=inFZY#x@RrslNCO--OtQig%0^}DR}e=2qL30JC2n4 zTeJ+bvW!^N4ei1&Tzhb>X4A6{%`?~clth$I8|tkl3#xzqB&oQ|}MC8?7HLH39;fB|@AqW;C}< z%HWVKW#ynUY!0f|Mh*>)lzZl}-gOPW6&*z@!|ZGD36xbf^z^KD;!lps%*n9`OFVws zKY5*tSghW8H#!17m=T@53RTW&|Hb!b;gQ1k9W#Q2cC(B;s z79ve1`rrbKGGAOb!G^a zXh-Dtlt}48O1RG<{brNT(|-P3;C629ipnIPs7_phEvD0%Jt+ia|YV1c)M!n^djVz_R@26 z%Zotgx;Ee)o^SWuGVmR#AceM?QIq{wV7T?rm-@}B6|t~}7jJK1@7A}CZ@AS9?-6m! z*`^87iDWVtH5fKt?AL#>fljBu?Uk7h!F6}KI1GH6duld{yj_ipXK$2gbL}VXq5g}Q z&dR3~+RpiB#y7GcRWjHpGaLyi)m>c5iP_HNqsrmb2BD3|`f+FLR{sxWZyi?E)`gFv zD2fUQNS7iZ-JL4ZA>AO-o9_!bDzRy#8>FSBbCUwn-F4^I&vU---uuVzx1Wde zJg&3mTyxE}=9pu>?-=hu?EQG4Yri;*WDcEp!Z5T1-nLO?`9$)ZFN_;5D@lq)ftNJ;bBWqWbh{ecQCKJaAm4Wo3un*KGAiv<7vaKXq#k;mI){Y*x=~(5qJA zPLwlj4^ajvAz<(`mF0o;ZD(c%89za5yQlZgtK!lUEK%}V7S zWvm&^%f+TX<1$UIowt5?*w~+khVY3!+v2A;^u3BiUyM*9j($)fuWMCvSiLF2v(j9( zbtbqR@j^daRl4@5LGJ2Y*Xp9ams-k2u2vt=^w9vpbR!5>@%FC2Jn?{hT+YBd4cIQG z2jt`h*aRUX2_kAI!`vt#U0MCH;F)JPH8nY|EUMFc%?JbgtlDsKxYl@g+^|3iOagef zoDQyZ;rftXW?o)*bGyE0HV$rNZCAs^X;9#Xvo0T~AIXSMO--FG(Wf`P(FI+of+z)I zhhZ-8d;zo$<@EAhUH3ZL7!~e2ISX7Q7TOiug20}Jgd9N!RulVSz$0@%k$%4exdE1uZvVBN7&0w$J^@RNtt7nuJJu4WG>Gj zmkmUTc7zcpvGK>Bj=VpetA0B28?h2UAR!@n3Eo=ftT3n8n5ZO1KyqFX5lxg?#3pdr zFqfQcw?_-UtMC|Q-kLQqs=^!x0TNLYUp|0`MSl^zzkhFWX=$|uH}t4r&sA_$WcI<9 z{Rz_Fh#e@+2Cy13LJOFK>=#f_jPY)X+@v!w4+7kizE}=e*IEuXus*Op=LEW4uyj5jqNvxjM`(X2+Vw zvnd&;7lH%MG=FSKeG-LJ4;>wyoj9bkw^v2{R2Uf64kLEA3LYzHL?aJtGR)QA05cjczixMoxwpH#?xbXcG*bXt?d=R)seK=UP9;4zP|iY zHIYe;gnpxlK{wmep!A;)4g{P&PN#AygOD4!hzN*twU~_^l$KUNIOAODr zK_*O@)hK{4Z%)*>D8!#n6?r96WjiI73-^U5C4K2eJ@c}*fn;xy4L*|VQZKv#Lj(=)%RHNUn6r}H75pt6RF{!Yqp5t+*T82 zzcfwHygUa@dQf48Kt?Yk3=)pZ?HEki?j1z`WY^iz8`Hj^_$*S4Z`~R1=VHf(z1yuV z>C6g$1<7r<&|kmI@bK_Yr>3dcqynTms?FDvC3LpzKu8w&Q@55cpLX9c&fv7lB&SVY zKLUX(ILND1bY-MAT!|YN)b$G}m;|kr7hS(@V(|p7*vv02 z; zsqPh6q^>zra}}Kl#+XJlk4qO&!s78U}g_!s%qQW#A1Xcpkad*R|+*L1o zeQIje+O)4?VJUS{`X9Hv6njRP5)Z&$*JbJAA$V%LX$d0Ct8|$2PrW{j6rSaZJK4nXaRM^5jR#^RWABG(WgwSmR~+%Nbyb;Xno*! z!a4|2a}Af714Dw2-0BSn_&Z=sNT0cL!I{Bpi#%VRJyYRtEWw3ghKq+^sXPdBKV7LQ ztP6W?b7`99OkTac2^C&|&0sV^1bA{r+7Wxg@S*czJ}cbJs9r*>z{ezIZ& zskTGKP9$|_ZMg}jvqA~G-w^JLRq#^7$(T=I>B_$2<~3)POy(!cRU`;ixr#jdzUa8} zfj~ck>cxu}EBO^O{ijZAoL?c4WPx)=E{ka$1wpR5PKQh=GjlZnp1HOdtEbY3TeDV1 z#Nmj*;F0w8nQU%uK4{CUD1|LbCl3@B$_r=5PP?sHahVUKcY?rw-Pf#yLyMIK#O|jk zPAfmOHbStP>j3rQXYLD;IA3#c2BjDFAr>n zQnODOx=6Ps^6N591v-m~_ z&(U5s94b`jX>DoA>Fc;SKdoJzK>LKvMu@aU2)0A~i?dD{fXO)?)g8(3^M2zo-!+^l zb}K0$cc=nS`9NA$dSt_8N_CMgQdx=98amfKeYI(+%k7>ne7TLP%enfR2=ha5>+AmJ z$V{{5Q>W@x{n(XG83~KVLy}S-t zd1|=@b*K8sHIce}yEKFv-&0uMZfD2crdQ&0d$4bRdMeCm%1-xS$4}Li_YH`DeCVT; zz%{zYA_89t6QTsGx<587+>v#;pz_o`V+)aHnkhFByquzBsZ%Q+T%FhH(AG+8*7BGhSk>B?sM1wXNKB`N3#Q{WFe?KewFkySTxK%7 zl_0!m(BtY|UG2Q~xmOoU9y%DwmtX;Yi~D8(omPqchdzfJ6KyyQ-kON4~pL{cAEN-&h=w)eK01o#pOlVv|ovntQPHoh||lgF4Waz z5WR*>#1KU|jW4RNmJzY6Es+$e+ZD+LNJ$a1TULte@zM`vToX-pUY&iURcc$W=XNLs z)^Tea)Vk85=QVT`rTko%_$A997>n~6aL1~R*DGJfUPC#McNKfzTeCe6{=rH3)aw!E zA7l#UIOsR5gZ$V3EnE7hhui3Av=B)1K=b+BePyvmh+<~;7b?U1Upx)uic}N#M8i%Vpe~-cKuCtH~7*r6LhsYliVF#*a69AH_NzDvbwryB7p;pOf8D-%J=u* z{zI5@4zk_&aXT6;VE%x`BeujI0FghyV7qECq7(2PB`+kUDL?+rio_FNOQ?s@$ z7L!BT1)cq;*EnemVpvlNjA0R};Nw0pC@4Fb^4deOV7C7L&md7Jw0bNnix?y_pI@>m zDk72k=u?zW%*FUW>1l4C5Rs7OgD3)6hB!)APy&~nqNU?L_OhvtY%nCh+Fz#zyYc2F z8}U7~*7ml*c)&g5?-UhrZ4#=L+empY8B^Sbc_CR(<1H*LZ)v5-W7jgPL3*EBl*?u{javqbCmzxQ_=Ndz&W8vbT%YMH%kkPOv{e3w zNYXTm@Xp;J!GXpK8Z9esz=HZ!)1YMGfwAK8`_}Wn&zO0l9Y3%pvutW4n*<1q_#S*B zw>iQ$?w#Ptu9S8yI_6|OtWeu48M9Ug;QIi_VV5=J0?3FM07$>KK$-o;mMD8`dALkt zsMNd|2N&mobTwC681vze1HOM9b%2SLs-@&D?+E-t{89B!KfJJPN(o@%6{xM?=Ra_Hg{g=tp^(77r%uC66 zmRJ0P@;k~9*D%yGw_rnCRNZUPVQ+j@V4Ya#gmPc{FJk^Jx}ybjMZXDSss-nu6N-(?CJz-oV)`Y+42 z0feD*dkWjDbG5NhKI4BMURw=iV)=;q+G*|D!=ZmW?s&R2o32vQ;gFl?NPq%URt;-c zDZ7;9_oY7Xkp|SC?!R0mq)G%UuxwAzbILaVdR>$?x;Hby@xg_rzUJmhLh01#r7DuW z`!Bk8kz+`wwO+WnFvkl8jJUvXaR>OG?Wve)g&p6$@>wp%hrRGm&>yP&Gn~Enx7^xA z5)o6@F443PTL7s&>_i+QZWt1lPmc-#>J;PVzhv@KQq(yXTUa|FdvEjkG|nES(Y>vG zT2%U~mZ-=`1dKiF->0eRt45f_*!aE*Wt9V>VFW1|G&VAM%($S-1ERlOves`g-FhSb z2EFl5Iv_Gj@J+F69*JmOn;{np^oPJiCKS%nd*vk(%mXVN_mT8Zj^Q6ZH^V_b=0?28 zTk&7lZsS1(8nNrjW)T4UXu*d9Q{ddb@I+y(51+OM{wrGMJxwX|Gz(#@ag~`Q;vPun593e2+fBOt_)u-=j zX!=44qAofX{lf)K+yqUeGKrH3P*yQx@1ensY&##QeR^NX`$FP{>KMb56U~>O)@w=0 zj_qPd`8-rSUnT0i;Jc=diSpSfQ2M2-y+=H50>ffd?l2XzUO%8Q5AUjZFa2EnUhNq( zDA;aetUZdp_Lo4t~U`nQ~|j6kxlPx)Eot*A1?H1K82U?~6sVkYatk)8SW zZvYOI7Cs>11Tr-PLIah6d7X(2PzQSAT^nlhdMQbV@3nI9!IZ-50*W0R7`&caK0Jv} zj)g-)slqBU!+_W=T8li3#%n=F*(f)vV~xlcq1?aTcK+`Ai}!wh)tD%}Fc2UUt*_xK z=CJJkn;z}l2nN}}C@(9jbo=RVi+kfgc(OJ+{wJMyebGVn<;(j~=cW1XBL30WM1K*H zVfx9^=uMz}+F>OQ0o-*2yxbu;zSJqQcSlg17wo$kPx{`T;N8i&z11Ib4KP) z^_@$mKgY}Wdy4TFW%!#e;avI!j9@V~zjowJWl`GbiveWp`fIKX4KHPOYr6uD2Bm8n^gw&%}0)Egf$Tn zU?}~8I+%3f%h)W--qg+`2buX^2b7S2m6wOAy))-gbg~6sl3wA1*sFxxClc%@-t(C> zCo4q}AZ!fZ`sCUVGvJ8=e{>53s#N3{qnOG5wL^GzT_6wm|D8z;{Z}fnNRyo3eH2fy zlzt};gN$C3YYLK>C?RD3F($LDzteKP6$Hs=F99w6*A&qs6(K})2SCtd&Rkc9j z4gTAd5S;W9)8@sWI60*05;X+r%_yWwsvv}p66N@JOb58*e?#{CeR}t$*<_^3i zMMc5D_|M(^rhTD5uIPI)5VIR*J|lmZfbsUoil;je@pWuGSsp%JKF+On-a^z!>@9g| zndr{c)2{XKOlK{20y|#k!7~~`y=QUZpyzI$(-usm#jxysaRBG?bagpckN*M$+^W0F z=bCFQ4%gM|x>iOmDSf0Aq+l{n2SPXw``QJUA}JWb=m(FxE8{bGF)oTQTF;pbwSlqIsK7-{W&d@&i6mDiK6U&)o*;QM|+b$Ghai24flya z`6Yei8MyMi9fcQ1>*I=Dc-N5kw;J?mnlC;jr9g(6Jm1-B682*SZa&-@J#D28Rag&4 ztLuF>6mU|hzzrQGa|u=kh>VLz(9n`ru~C4$fYYY|e$q5h+e|Xz^3o&CH*wbOpnO>LNjJzc{S^@;k!zJWvg)P8(&N6@1MAa_&sNUNvKU<$%GfKaKD;UbIdW%Z z#$EW-wAA!MLWmlD9COFjzHQHRRqEOZ?XhNHcmRjp8d@UG7^D2(d5 zyak$uIpL4bU>g&7(^dk`mi=-(A!-fY`A(-R=I+~#+5kaZLwGl#@0?zq6*@NF^m5$o zU@EmnP(-8yUG!fAGC&yhHHoQ8{p$R?ra~@9OD^&I9-33!j z>b27<;l5QDJ6vK~?0zUaf==7A_9X~SrTQw0&SkZgfYxGV-~`zaq^rF60lW6&c)g?C zd|u+ptfeG(e@lIIwQS3!W6qvpDn>c)mIxwUMo%Z7!8EVmL8d(S>)4{uVJ%!8hSmsf3v?#Flv`7}O+xFwmARzl- zgNg!M60LuJ<>3-3pSnv5PU!wZjJOQYU+gOsFnhVxp$V01ksOkD7bR=9R(l~nLVw5`*{zJ=4O z{l!I6$g#ao)^nR@`t=og8e%_PQwL-g@i!0-mV&i)hZ11v_`>07Vm34J{b|Ptu z_Id^O_E_$oyzbQ*$=B^_CD2!?v7y45oA$`uIlZcD+{I%UHw+U3x{b0ag7M+ENWW3Y z#O{EwX=SbIY-ftX(XO9TxZ_qw2+e9sKAZXjh&xR9@QK5cXa7$&5)PM5QV{ZO4l4cO zdh70!z~$nfNlSM+>mONF%=DVW<>s=|LQQ%moMdf`QaWM^o{CiifjzyY5p9aONt@A< zqv^+0^MMWbG5vNKGF9qigKBbmr(2{E~q;!EISiV!v=+{AI}Sw)XERUW#rCo_+pX)5!x5Bsh8J4-t)s5PmY=n zLvEAPeJnzbB}sXq15bR$vF>*_6Tzjm6KZ>bdVM0W0FibJp@xm`dIYz>b=l><5UY+A zebUKg!7oM`3zctko&_z-AdZJl8DMZpXf6EW~%}OMWAgDi40L(Z1={#?#`~BHp)c^lAxw_OxdTA7GCw3TtTeVzloY9}atbm7t&3mM+6NHGgKE7jlu-HTp!}gFR0PC_`fF-rzE(yKQ)cNM>grRsET467X@q`p= zoYye9QHDmj%ZvT z-zqU5Y@4{~6WEe=-+z*&dpRG3g?__R$NQv*msD#1m*V#HuL2{8)>y41DbHE%mBxG)h@AM37MpsJYx! zsq4U+DC=2KjAO&v6rXm-ZmipgN;{svnrEiKR&f)3n0z4}Td2c%;;VK~5`XcV;lvBe zynCf%!qA5x@CIt$I%=}!8sunh1Pb@FK{HTS#il?l?Ra)M*{KaB9+IRg7sRfZ4`m_JC)DY)i~;kNs~AQ!@>xndBz|G zvFc3cLj7NGMY3*>dzMhY@MeL?{|@(RcxLH!1@wPClgB9$Z7NDVKlo%UCU?{sGq@z!-4z~Lx%gk!yjjoas0&dT3sUZVW z)Dye=zqOZB9PkyzI%>NM2HC|oB{?hcJ!`;MIF;>$2 zlFc3pX-%c|h1Q16d!pv5_bWu29=Tj9)r4GI7h%P~ULl_VtQyev6iZVCOCe} ziq=c_USbNBE)gu)@=mQTSb8AxvHL9UhZlSX^9Q7NKo-2R?nXwbqhaznBfS=?XrZ20 zf}pswGcz8Z92V8#hQP;quUk7i?Q(ssHEE9NT4L$Y-mh?LcsDL3Dce^qS*M9z4%h^x zVw&@t;uf+zL?IgqgT=(2R{!Si2fA*@?R~zQ_C+l*F&wMs#vik;)v=TGiN0x)y_KQ* zs0rD~zBzSV<43pmq*ifJf`rJ-w@A|su|ULi(JjkcUd3d!ULDqxZg`Xfnym*hn;?1l zeLJ+!xM**;&~VubQr*VQ;mLgG+s#0ka>{8r`Pfq!Br8>&WReg;E!g{vW~W?rKqFj9 zQLz!|vPc5T8f5X;?zEAVZ2-H|>M!m#+i=yob%cxQcQPN;cd}Vijl>8bmp#?Q^Yfsup#2`v+Z>_IyDrR8E)lSNz7_+iY#U?#4pzsBX#8`f{ zsRf%He+4M~GJoK1Tf=bD2Nf94cwrg+`1CoLT_ z5@Ag=VLY%QUV4Tuw}lYX0P!@SlKv5>mOCXLHnRplQ4n^+dMhe-=y`DfnV#1lX<}c{ zL<*csD!)hiLwB&lp}6jgw20ML@9CkqGgG`Gb$wdY-H61g%TJG1s3w^NFI&;hKR<~c zHXuJP_4je<%!*8w+qprhbGAf(=`Ci1b~NUm67B%~iWWK|Y$FB>ui=TzMHkUAhGNo zc$!u@2w2fJRM^l~P;<|?JFcG;I!7n<+Go>X#3y&#XVY_Ed4UFMd;?8~@A_BndK)L& z)z)v6A$dNaiQFvgvDLa(ST`~|wNnlDo?4*2``mZEjSe2X^zk_w4yK>mc4NZZ7VwY; z;n4R8>bpQS_coB!e<;%Mv1ZFVbKDSAJ5B_Jb@qNn4id6`d1IPVm71-5g*o&ywmH0I zP@$o(Po&Ru|5sx$@o=oVp4+?b?xe$d9U4fEpRBMVuo-jm1E}0U_qohpayiv|P~f5v-ysn55#GqVzIvM>{Q##J+eZ{w-nS-p$m z;`O!QFbH8PSa!p2xVssSwKRI57h1igj+t8ik*A)8n&U-0{eW&L0p@S%KNWjVdpa(JHvGT`R@}K=B*XTrEh`mlbe-OSY3x+VTF}#Js zJ<6wIswif}@K4v;|I;#dFh6$4Ra z%6FB;s_{z@CXb2xBSk0&yu`jJu5)XrS(PcgNRV~%whl&^pn_{ak`*{@CKcQh12N~a z(o(H4nq$J$f&Px7<#~Ek8R=XTBbk?`_Vi1R;I+Ev49cykVe({%V zin~_K&b|$)snb>izrlGqzkr6}F&KY&_nC5oPh3iZ@30|&pv=(y5TP8@H^W8~f4Pf^ zO@vfb6N9m1*3|W58aO1kWR@#P$y0K*zJ=skVV z?Wh7OC0(Kh8EowFw~Tc0yt`0dONL4J|liF9| zgpM*HmAXb;kFR{S%)~b~U~ecw z;`_FJ?eYCP*yw(2{^jk)0sf@|xF(Eh445?`Ls38ZDa_v-cJ$G^P+!8Zu)E{(4xOvP zyM1W<-bcefA}T^$%e@V=Lm{jt;!WRkLJkeEE(cCH`FY1D7Du4+0RI+cZ9T}`x+=TZ z;KHk1cNA2Xy%g?YswkHeqe?+8;vz-wK>wD4jC)+F>Sw`A&+>|XD18i&lo%W!70%zF zDj(vTDxMnaV=Ch+lNX~3|F0(UG-~Q+u;rvFHSNvn(o9`3Dm|^!YJ)tN!V1RGzLK^P zsCBKt$pVCn^~TTlK>O6VMZ zevdSAO^SgxK78y6(qN71_-c_%e$98$ogmJmq}(aB;x5Zb8%sC{S=3XGjNvKsa##Zf zCPMPxChCyL^ZXvmNuy(~iV*+rp#w_oovgto73Ok&McEzS6SW#Ps>rH^KUJA2+=BONM;fxTW=8eUI@cq~W>q@7?BdEK`*Vx7=%fIU69^1T=fp z8WM222p&}6g|R%g9*5+?v0bPAG$XO6cuH1*N7shUUbGC6WS%DiaW#mOk$;Oe$G`8-X zr^XOJK5k@eG%G7*{#93-mf9xI@K=68(_#UYfa5J^L+Zm{uk)hXbZY!1DL-^PhRmj; zKbfN#mqQWpDQ`Kb^BbLaz5~HfnX0Z<)UB(|1SKpP9u*}uJpU?Z;2U&GIec|6pz*R# zKKGhW0j@=4%Btm8UC-0hVU=oxs}wb?7^f}JptuFu|Cb?5c{MdPrOll5(n{WbE?_o- zYR^-QOpU|i!}~wwXRDiQL3@F6PQ&)sRqxW>PQh=70Xr0m5jtv{zWIS8AMz>R=;*_H zk#Sy~6L**5ir%{wnEZzdIG0B&i`hmL6}SP$agY0-;4u?qP!q>J2rnosb$yH% z9UbC5_qC{_B{A`pAWq1mPfO^s&eArShE?D`bhHN8&3@gjUO zWHM=7ei#b$Uw2)iFC=g=HvY19AA8(jikX1C(50qc&5RIDRS!#Z?rcFL-e0mkhRpkZ z*l5Yc$;!@l3vEMt5z>9w$X0;how$DI`|dmawwN;5;l16Y+h^!#K7Sk$bhPWo*SRRP zvu2)Ju7^5bIB6fE-_qs%5;T!54Vt%;Ll+-j{kUnV?_FIh9P?mhsnXw(q9H%`(*O`5 z`*HZwl$DUuvban+8tiZem=culIeFC?Loc_&06Vj9&?k?xb%xuS{`Cr-9*Xx$YNO)ytMS%5!P0?|X_kkV2p*EY1HvA#;W)bdV*#Pn z0pLk~Pfb|c&wl=da)9$6%Y}3|1Kgh3V~1}dZPf3r;kz0*8g89Cj2qToSqH31#$Usg z{s`&DS{lm0Z6^7kA?yd(=J7~-#*K7HHj%UTY;sE{$X^oUp9^pyk3tA7MlxY1oEKuh z$EyT3x}l8!@{fPJ$C4H5SMrVyxM6DrW&YpQ{uz8Za_o{qr}V&|`t9u^H%dvDOwc}D zgliOAg;r54-Ta@^k=OhCL;nI!S}8Awrb4W{bXPPEv9VWR|4v!@e-FXmnJfvUM9Rjq zgMdcQB4-4VX;LGo-#VHC$HQLX_9ed=)-d9DW);>L$Kk9aL`kWed=^=AJMUaSvTNtH zSW~4)J=xV(YTLyac#CZ3KYQ$s$S81CO%cgKzaVC2UN_G#UH4b^C!A~tUKjlUdQ1Q# znB3$1JyhMGj`w_ASjtN7Ul@?yzq$Izpp4){&$>_f_cwqeA5F=JA)j6uMVBVMd@zrcL^#;7#un!*(4Xu&LtjSZ40#H{c%BSd5-P zR}cE8Bw(=&GXQ=br-uafg{2pR*RoFMpHgnlPdLxmP|Tgqn&@!Yv(>AHi0uZ$3D2wt|bzO*s+txCP(OS(Nx}OpSg+@`fgo&u{$`aYQeayS>b*&Hrb9e&85S5J}cSnSLho0 zO&^pz{gMavPn>W(33{=(`k$5@8f zWtUKVE24`pQoO`+r_gCNSzyhpSMcy5@*sMe%W{{3=?LH9RsHdMI$UwvG4c=RMZZi7 z_^wng-OLeuaVE8gjBwKWLVNY<=*oNYmq329_6*vrK~~_c1!{bxZa>2Trd?J!(-MGt zvR=}+?J8KdhLpramQe0nUI=6;c(py-*d52_8wu`faL2yvaGu&Q&kUV7oDrdR z8gertbrBfpo|Yr0RXG>PHe{i->dt_Up|g+Q23RD?9OzaS3aM+|ZZ;0@S@49Z;D^^P z8?n<&OATyF%w1d>2itz?aNu_qj(XY8A|_}IS-(bJBmI#fi03ZSZ}4~I@0WZ}yLHA> zA#^NAaa9>pkLqXJOl|s#5N*cmg3qRQ`$k_ahhO}jy6w7@X++X|i~7dm7f6|w)hO1$ z^V9(wt8V{Vq2R^Cy@mubWsIVv_N^nk{%qV|JhrsLrv_Xs&_0=u3z2UttaU4QX?`%& z=-bE7wMQFDdZ)JJB%_6MG0=HguT#U}`s4HE9rgHwLHA<&-Q%r>!fW5al|X~nh1h9v z;PB;MOKy}EbGGsx(4W0qwHxCRUs4%$x3Ky{;-xd@u%5z3QL5XvJh`g@S}N1jx89nq zRw9DSX_k6G)i+@Z?nws|p zYD!q3#FXc$d5_o(kd!n55^<#7$8(-%8x=RMU(1wF4n$bPp--RQ02{RjihvJqLKEh2 zP;eVJOy)ke)a_h6S(@#e4@uD=6h{0f-+kg8zHNMf^wBz`J|^BAEO*2FC{8HC~mpeH0Gj-*M3h6NAAScW4(!I}dk=iNx z1JgFVCLVWUg0IU;t!Q{)wysOICMAV(*|3I-6yADhKKl6U(tf)Uy8#0!+MH-|!fNVZ z0)oI|Q}lXQ!;ObJySK!@`{i#5GmTJF>h^&N^+_TZvV*9o?KX`^i{`^-G#X5(J38dA zdsE1G>!JXLAq|U`ueD>eM5JkJ&#r&C;k(OxuZ$Q3*(iHcO$#dR&71Gstia^H)1bws9HZ2hy&Jx z+XkA@(C&(A(398me&p=uV<(aNjUvb!Z8o=0m}>UOZZ4WbveWyy6}e zq#ntU&4gg*wQ((+@}FsWl%B_g<}bfwr$r2B@=NyQYnm5T?7(148JzL? zPB9f;`2srizTb+g#5zasW4KzVi9gfE`thBQOn@xV<#LZ|i~sGn-6~!*3Y}$A$^jt| zU||CdlIASZ49fQX_{>Cp z^@eRB{_JcCZEey$W&U98JIk+-o@v$@?mYeHdVNj&3lDDAon8BChnV2^@zow%=e~YT z1f}(bMF-1zNLhUE;g{6JFjbiQkch+Gne95mltdI7`YeyABWCn!&BzlDWIQ# z1(^kfd2fFD`t_q;o%;?Dlr6Wt0eo-3^fWE+iRapkt;bOK3V34X7jZn zrjY`z-Vbou*-q`v#VsDhb2WML$G3R31vddDJ(xQqN?yH(=B>+`DnLF$=J$<=um)&VlW9$D9}_4d-U9w6=>?A_+nAWa}t9(_j->A z?*UBo7znRxmYONvl>m67V4m6-gheQVmvkn&tM_%8>Ui}QuRCWt|6H5jw`IR#=}H(e zDM#i~^u}ph*gJay!3!F(wCye&*Od!0Zz%}u)9>l{P$LmiwutNZ9!pnxGBt|p)nQs4 z<2@#QNW49NcHotF@|+EBgkwj5t8;`a&UxL0f$&waK+)2c4hO{oMF|__qhPc5FFPpS zcd1#a*U1g{>wQV-iR)Ig{Lsug9*JIXj&FuIHx5n4q^^XKhh94~EG`pNjr4b9t~!Bk z8W}Ba@6;4x2Z(8$cVT(5FI*8@@+?OlZL50NsYdPmNanHq7Ps@0VBchMptak53!~}t zeP=&Z*6-iDO|E>fngQ*0+3#`t_yWx1lGh0laMlQvlZ%o}e?H^}e%(+?l257KQaksiD1ZXl$!izfF^OFsxSfPdofMj0J#U+Hj^`^}r_ z)_x3|b}K9Qx>&^nM1C;eJnsNUvFlj|oHbFK=jU$h-c@f+p}grlooRSN3ie<_tfVP^ zY@<|AYZK5rZ`x_6=mWtAM1YkBML%tGk5W0!aX?0tYyxMtb27r(6q+bCKR=wbNphVJ z00#qraf=PUcM(QUsoS1bD5}RD<#l)Z=1XBL@eS0$OdpP$!X9YedP;j4n?f`rwM!TC z5V45bR?7AO!ge|GV;R)LRv=tqA`d=<3)+m>BiYYY-sLXMxo@Feml0`aJD&CZY$}#@ zx=ubtaX}I}{InDDAG{KL6F-|kVGG&fs+MfEj+ol4*ZCS0kJ*wt3eOb}SBli)9d(nS z=N%JA)-A&I6NXjDHd-bOZ;#YItrR)sQ7zeQGje*>u`*NbwTJe$O$8N2mpy#?SEYH8zghufS-S3%fJnK6{tg!$nDoVxLYj#}ZsA6|+l7+*d z;WEklr(_$23yvX;Y)g1liw{eEU*X*)2jC*hj=@=M>bGyiYZiIYQpW71^D4W|ejsAJ z*dPcc7JgGgLmmw8d{N9%X7Xcq5lge$^-+5$X_L7`@I$bHX>UTAst4dbT8g=nU#^nK zWU~&tt_Qi#dtcgL&RAR_e8Y&lrXOQ1Rie5qMKvEiqUH)QI9s2|G2G(VeVE5h0#9); zm%UP>OingG$r)}t<;um?^#dUFaE(5+>)-(DfDrmBPG)B{)GjVF>8l`|tBm$c&`!>5 z)XA=nvChkDOJmvI~ui_@yLP9YwLpC(L}JY29mcq=iKo*NMtfJDPJsX?!N+ z*nSWdW0^;Wg1=%tzxxyLLoG|0L7&xy?+IYC`!-jW7&JY#9j*!}kOd4;n%UQc*&I-R zcY~9rKf~YO@oqtER|MP+V z-z?ex4@p+d6xpG&ymbgF{H5pq#>d40N-^*eNo4&`hr95lP4P8(Kx_{F^L>wG+0x%y z3A|f{9t8TAru>6c^dg6V(7V8V<&gZ`2?*$xBN2%fWY@*f|I>9hA(}}E^&ck4B=dM%4IzK(DFL99Tykp`oaO=szE$}5pt`bVwBpSIVR5Jj44P9v=msc1CL%-zAi znXvI6)A57{dOE?>B24&q)tZnv6jgsWrXOlejk=ea{ zAw?4$w-eCH6~ZYgA+nd>IdA6*rS+GW|1_=Uh_83+J){G(gSwi!O!b(n3V9sfY|?Cr zoU>|thlD{X6E){QP#s9ek#gXlJXdWworLpqJSjo^VE#=?8JDj~$#slvo_9+tECwzd zn#B?WVDn2?7{aH!bZDcukJI#yyx@jXnm;7@B~Fe87w|hj7ms8enUL@Wi%4qs{yw+M zJ(9^0Blhs{gf{Ur2D^NKR2Fox0K55`vXdo z!mQ(C<=VR(h%A4L5CFIsamUN1Q_)6-hd*`J3lkd!V70aVt~;m~`@&vH2}&btTH+6o zvr0`$3Pe7to%9{jb zEG_V;k;H3i0{EB*B8;=ld0uH;x3jEXw>IhQCFkvKr>YuGR*hRzL@Ke*1F-5HfaNB{I*ZqAlCZ?kHb^!zcW5a{_OfTz0O?sUnd^P1T2?>W=}^{xhAL zt<;6)!e04g(ykelQUk@=s*+Dry>(Po|3cM}K{01F@WUk31bET8u9*^|fl_DCLs2T6 zx7>o(09X7>h=w2Zfy&QkNMsod)lj2><1Z&RsxANI7tMnr9O~?3W?8XxFC?`8zKD?>fG+>*SLG6+!yvtl*y4I4<5ndzT477CWn_l-yKxmXr{OaVF$c#^e-Qb z2Hpyk{rv{(Hv1=AEIY0h$l_FQeF1fO@ zOfWtH(7EHksN`#PS`sf|sHD(|r+kX=W{Us%6DEX{xn*KZ%vxct2=+<&S~q^5k6&roGb5e&`xkv z7fTo7I&znFSgU{=R=%((Q38FEh5xl@;bUB}pGVrbE!T8o%h7WEB?YY#<*Zhol$&@N zR*ep&FUmnyGgIzh{8SPDwi)hwX~8kcP2}BU1zw}H(xD>uduiiVT7w3bF!tTd5gU)q zm|o-^i2c|6&~W_N!XiZJH!B@SebOS9USgIF$^j-$*4j7_Wr1@Go`I45=Vn*C`Ox#R z*qT`%xB~tUyb#&Lg_$k8H(RY*7rOkl%as9&`{lnlMnj^BSGM+d<>DA9lQk`97-!1s znDk{V>2Q(MRQ$gu$_@6#1o=CnCfwKggFOEs23H3x5R?PiD`mw0&6EY`rWHD^K|Z{c z1H)!WnEMZj_=n?4bC9=2FCMv(@JvArCxgo+uflS0tXytGAM#S>8y!Q=m?PRh!1C)+ z6*R52LVsek#9YD3cLv>a_oX-?330LXv|U3QSXsD1MMP}Ogc-cB$ac!vOPk+UvH4Te z;^$6^_mCN3qGqxP_Da$-Mclajbn5Z%y^@oUPBd2zUy|Jh;BlNB5h0Wi~A?T`bZ_d(m*aexlFn=mi{{bP36W%xBj2Q31~% zatI}Jnx6fH!>egTpm9q$y-_B+8O{BE9ujb(y~Y=#@ibm5#lv2Vi1=`Mn81MuA_Yc2 zWu0<9-R|xZg}10k?^#{-_&4ePb2kU(T@XOeM)Nn8;LZ+2pbu;^Kx~-xu5{4}vs#8ZdnLk!E1>CHliOL`Q&znn z);9_P?u*Yor*@ce5Xp+!gFUX&YIOdG4O5zP-g}Sa;YeK9X>svDm(sC`RU{D;*;;^n zGL%=u!Goghx)mxCEjU@}D0Tg-I@cE8z7qelyOLv?C@mgXkB;8KX#Ui>U_2t-!vLI< zc6hFwirf6hR6Ul4=*jut+}Qs^*LOfgv2@)IqGAHBf+AUol9ecNP*6b0IY`b~a;7nX zfPmzjGt4mLFoY2hkep|LA&6v#93{?cyx+aw|G&51ti>8=s;jE2t9P9~`_$RhwJmft z=Sw%CSN{C{D^dU5U!hqC(wK(~<*l4wCJJwT3<&hdp{~!?2d3iu#Lge!#4nhf4O1W$ zD;*2H1`=S$r_b0g9WQ5r1lR%r->B0vfb=7Xix0wHFlFB#%d{B#V?esal@aw~UAje2 z)Xl!`L*sgNheyf8Q+4;irYnq_pA7@4$o9ok>o22r%6CUVt8vIr{fdHiufmdv?e6fGXAl0+ zG!mC?^I23wq3ibww%X2ZYQg2oOu>}^ysIYnuY%}tuoPEq@1P@{Bl<5O4SjXc36-ru ztF!ild_ESmcrQ@;ZpshRf% zBGrse1b3KD8Gy#-zZT)&#%6U{?r_a|3Uu`vXi3Wd19Gk&7yMb~x<|72lS8i=I& z!=G`*k)(?RGcbTpHV~J)h_`23u06kWZFL;Cg~-*6fyTl z4dx z$0aJB@xD#^9e+hGx@}xgcER=Kxw~w~8)qUof8SN!(-H+5_Y<1%gaa;%;xI4Llux(A zm8}~d990om7@#!p~pC%0m<+?i^ zYd{0`PA_UvrUB*m#&RmJ2En|>mfO$Fw|r6)@kGZirolV-IGsWN&-+JC_-Zc>$61f& z&ifrdzUJ1h|8&TuEZ3}bX{Bb4&EVNKPX(EJF34%CsQ{`%P@xj-?@>qTyho0QQAx2f z&Nf%ccEGKgFdfP zrrcttkh%E1Mt?Naq(i5Qq+%h`#G~ra2Tm+vx^e4F~Or&3mUz6}c=@JNj)mIO)BEn?=j=V2 zKHc_+)6nwVnaG&Pj-S^l9vjK`@>Fl83VlA~@e5?$oKn&lq|Z5={^ZvO>OY>IpB}Gr z!O~9d48Hw*$TOLEYHZp`*TA##NlLbly8u3(G|%KS&}3(@gPPENA?;q-Hskw!iD%~Z z%7i(PvoP;6a#T}}dq)O&#G%{yC8)%_H|r?J%&ekgIhe-(U|-w*;2w6`=eLL^UL;~< zjgH27hvccr6%B6t<^VXAnPN{8XM^NzGD-x?0n7K(-F z+w_s|-WKFRaXmj~(;d$UMs9`lc^{Cx(&Vu&YiAQujKBaSw^yVaUN>dz6tiBBEBPK3 zC)4+v!)Zh8YJl>Qt{~vadR}$2G{6}qaIH{vEsWL zik61)tyRXY_DjoyHy8T@%~;iaW>chQmxq5gdl(O2tu?x|M|X{gZu)>qlQYm7AAkV@ zp4GRc9L`P*&-SM4Noet3GW5H~)Bi!?#oNW^(p}I^hJQfwUQOm3w*MO&MH_xb5s*gm zV zN(#)#izZGpvXx z{eZ8>T`KYMyKPbNDZeDC#{t{{*QGH0gh9nW{qVX)Xsq%#jG5lR(n4V|O9dvAbt_)a z;G4c?PX#7b+Wi?ca82|o$nsl=+h5pE>w6@|Jg@q3t_%=$sf1)WjfDDsm`k3!a zrsm*%FMu??HgYc|jd)eDpY59l@?oAWx6UpRy@V4Z1@xHm=*)fb&=ESjU(fw>h;?zq zp<=B=IK_W>7Y+h9GT&(Xj&pbei44NnlcL{b7>gO)q_1c;vuNE_bK2kyKaJk_WOaFS zsLrVRFXE$Qhnt!evqyzM4vA<;8Y@(?)`qN&$MCa2u@Kt_k^n1m%xQclbVY)*Gcl&Y z>#ljaeD+cyX{5{d(fg5)Z}(g5C?QD$%5@G+6mWwX4z{|a3>#4|5o}a2i@BQsvs=IE zqo=#pSrQb<(+yPedWQGySCQb1{rs}<_s(^1MXy`7x&|NSs3ZRZ+7O2Cvfm03%ZGd) z5BGzgq}bY|$+|K%U%|gO4>g(!NqYAq0I?YSsx?4GMlytKvF|b<@Wx-Jmn^uE=v!g@ zw;)8NZwUe#I9sTPhZ)?s$B`ia^YGczTLjPquCH1HNEZdMo4n;VX~#VrA63X~(vUqI z0kfvY+wa}MO%dS!ciZLf96#tPNx`_@_kIyfYlq5JPQuJLp{(=8CWp}u-;(CP$3-co z6NYz~2?}8~!8s`XUIIi(Do#P7zJ{h-Pxm;{{n%rIkIgbEGR~Sx;NefL5xLI22nDp| z{g-6K+vo9Mkb1I{-ZE@+$6o~p%1C!%z!x~A^hYE!_SaK_m?T%YvgS7y zoY1>yfsN})fAM3}^i&xC-Nxgq{y+pFi{e5&7`7?C)XZui?d_lhb0=zjsgz|k-ok>j zWtXVAygamN=>o!?4L+vCaQc@(GS|JwiPwmHr5QlaBNUv!m1}UJND|?dC{4^GhAB04 z!c93~wUG#nR$k(sd;H$uIncyQOYHeW_;fR2oh>1HNESPHU!KKf*Zwu?`?aG^IuA;g zVp=^-$DyF~5X-@!s->Q%KjmT8;*WLKb2i*BzY9YNsTt1^*qqiic^_e5NK^a-w7Gm# zohLQ)_Cd7x!JQsWFlvR4iBzOi1Jn3o@!1j}DDpV?cCKqX3an$EZnqzemkO0FUaMtg z#bKQ(>g>qY{2ojm&@}8pCVlB>Jg&akrhhaA*|;$V^-HEDzVD^o-2t__j<0JBO1xSas4fG?$(GwPU@K*U~v@JF(C*6ZEh;Ok47s8i^!7c?N*A z6Ym`VlmI`tdcu!cL~uS_c6+k>%icih$*moC@!G-NWVm1!&~$GsVIGzi!|m|2CIVHS zF!H{cOF!+iY`xCONrdk%dJcAWMpUFPa70ORT=+;w#=?LHRvkz={wBE|c0WBoN-vyR zxxVk4(UwFIzqfyA@5`rSuKsyT2WG%ggbS6pl0o{zHGW^3nw~eqL+0p6jL+k2CFIAY z5@_t(r?QH(ki5y?oE9!~{WO*8*`HmN&i8LR_4)*VJN_hH!~uESLeNqYrakOqQa(nt zHf*P-!KQ*r9`&G8w)ETic1F$f&7}1jQ8KJQ3jN_nh$A&DLOs@`n^v;x=ZE{i+p|*< zdr)6>+RRUn-)%}S!A>r0Lu+So^sg{w&yCQ8o?z;iw^xdO?Ezg6-23aLhv;T7^VhZJQ;j@t)j|-wYW~u(g3Qud*U)i>oTWmI?bJ} zy$96H6+dDTE?#U`9ox+HZHoXOxi7UTQqnnaL4;xRu$JPlvCz$*389W1iILsJbG8Nq zJzbk0YEVU4c-Gi_hyLm4Wg}xrPcEm>f&cUv^_FP(_g?6yv~5_;yUqfG1$?% z=u0{uMfr{|(k(q_s376ZY0AB6ug%&K@Rjd%fgLe*ditxrNX%EhSt`yxPR%JK1qA)& z5`zZw{Yd2x&zcq|?8f~{%?~sCnKwIf+vf=wAK~{TS~j94yjZiFZBB9iEPkkH>Zy*k zo+8?}cJeNmsi;%3&1L{~PVs8GHp&3-yJBnOfaQ55okX6FLHdwCw;=J|;+1A7UH19C zH5!QR_0P9(=;-3D-CRw|r}jArTtGF;AVzOFOhnsEzw7+*j<7?D|Ax|x#_kRGr?>FQ zSD-137$0B{tSDMj-9zr`oVGHpTC5%6(W&1k!v}=b5y=`6y`5D*l5JQqz2Kq*W=HK(n3_hgPY>6Ty>2^e6D78 z^kfy8Z(tRtoCP+Dh!zZiQujZ*HTV|7NR{W^hTlS*)M*GL zuCaY&5BdCi>SGt?*RpU-Ofym0(Fg2j&u;LT-T4-Yo$#6Lc;R;bxY|`(+3!m&jX$|S zx);d!O)7TNvU(M%$LKSOx7=*X&VFLgn^dpE_v0 zfltSK@3s=m`?10MCl$?t`m$8g_6F?F8OL!)MWUSDRoGO zYDni*|5v)x8R%xsEBZ!uVh4_}We!@8K-c-LE^Jc+7Fk9Hv+MGzsg>Xmomz*ZLkPoIMn<8MBoTW4 z=f=RNrrKE}`0mWtd#e<8?U-3}+W)G^aU zE14=M%Vjr7Bc~EG`+PK}Dd*l=ECUT?j)g|sc2j_^lDNyb%8pN$nFrD&e0{e*>$!| zV&Sl&y%EH4MFJNBdLP$n7w2JHBqv28=4P)B8xmI8FV6g!Q#UEHFcXoFyaoQ1BiEPo zk5Jx#BtPq)%rX#2&gI^JH1pV={mDWJo+|lj;2(9ouXp}`MI}SQ!2ycSYRaYwA-xw_ zd4ey*SXlmTMfLq66XcW;J#N*fPAEAAruC7%1(pKq%Ls(CzL&p2@W3C;RL2BnG#iK- z1T!c7vKJ-zA|Py5ehs1rW+eLx<_CgQX9)kJ%);E`pBU>Hk^?4_8Z*{SY}vuPH~=h> ze1pj|WFblO)zoreYQ`kqnknT+Nx#)hg;10Ym%2lI3HDg{O5< zcO-XB1UWU=%}Mh9)aIS^fft6h!Tw5QLf5vn+o_y<$%(rPQP7p~Fh8*8iah1lgLRb+ zgMD;16U{2ut_IGn6F67nx&h*^kR%G1xT&I&Jmn%BB!JdJOHFp%-4bUUb`(iMXgC(j}=8$Z`#Wqss#4E%Sy;ND{#T;^$eU z(Ly@7ljr~G9_UJcBr4w6tn^~i9S^$|ALy1Zd_7~~{)7V1-!sw#;KORhkuzyR)jF#j=e+I@62qSH(@4-0>N zIJ-&@>779xzl=!)Gk7`I3luc+6oTif@z*vgB_?*J02a%c+4^$u+Yr@GJKn^3_q;-_ zK!tu0z)Ja&Ra3jDV1ZVk{zhe`F6+lKmSDH_^jY~~Zm$&)M@Kr&s~Jbg3=egEUGpCc z@&^9M6lgr%$ZhiRA8?PX}A3Eo@siMG#kNwm? zHZC65ELk0Q-@P|%Lc>aX_2d@BDhiwetAI6$4LIOJ!mnah+&N5>gN^mcT6(d>*$3F_ znrVmofnrTvr>ACRmCd&3q#K1|KcdMA)>0)lhjT{WebPl;~Xm{ z&d}h~%cU+M_gNCTG=QY2Un{6Sq%U_8eAykyDaiU>`n9w#ii1 z)H%C|sXBWzqpgpHJc#}0=?yN93pPL?Yj|+IE|JM zF70+S!0W3qRS&lyYmfF*-3sl~j}08_?XjPJ)J#`{B6#2WK7tv=zF$BDQ0t9I0e28O zO`-K?+##}6(tQS)!)beF-e)jJQAiHezcnYjlokoI3D|8<9^V5M^V-RA2MOBP8cUN} zodz8_|4UXO|8_StHWNMJZnvxxA9$4m!ZeH?ANd+=B8Ii?ZbaVWLfGQ9v0#qI!iy}F zfXT&Klhu?qVP%e~vlO=UkTI3jWNpg8+b-#zEMMHXUK(Hy;HdhhO$I1I&W(S&<7SaD zL;skGjVi4D6!QGmx4x_(Srqf z7PZNmJf@w?ZE)tf&d)&dA*dkuKQ6P%+$wBw-hwmXrjg~n{`U)d5km9U!TaV6N7s!k zFVd_1>9G71CXP_@=C}7h|3=w67ir|&Ceg%3`R(=s|Kr#%n?`st5tvlcK^sge`KQ>Q z3f{nN6&*;*J%LMM1B74Mw^Pcd8N=Gv`)N3@uO5RP^DOcnU5 zD`cM7(@a`{w;R3OMj&bWnVl(m983f(C7dW+rFd*}vjbiuWM*<-;1>AM|Df1nqk3W% z#e7|c03w=}`MKv_bP7p+`P8^56EyU7==?UI@#C?4dD6Pv(;JYn?Uum|c@`;d%RuJ} zXOEuKipn<j3}nMf5YbF<&fakR6!QADK>e~`Y>-d^Q?2ehS+61+!hrQ(rM0p4 zn+3OQZTEY{b;flPyJY(ds+C~ZP4sQ3i?=ija$xcte$Z}uu4tihIpfxvGa>>An~?w@ zyih*hY22r3xk=E~XV-Abd8l4cTPZ!m<*D!@?UMZ?J#NQR*Avo95Y-1$X}N-l`mw3g znnCMs;pKc4^X>ver^9m^a@K{Kz3dtpVw{e0vc|v~wvix%tq2MCk?OR$8^by3flHHj zQ-BOSx?0t!O6!a))X=P_GIg7JMb-~+2_|=8Y)!|hxC(xA27`$hhs$KZh>z59mAVa0 zfp&0vALd@kzz%Wf9-7BAN}CJ9Ps>PJtENA3pp2ai2MgGq*Of_AW8!RPYd*<=c_vj` zIpr3nZ@}5|o?OrHI&qjHV}E488PTt1$~h?D&E9Z#LK&IV&JA+-wO_0hBeA`j(3*Q?;6;1W`LC)J(kH*iH(U{J=y1HA>apQ zN_?KTG3QoE%$8QSeK+NLOc(r8Lsc^mB_2&(s0*%nBQy@W-KHa9@lk=-^K1%K5u=jzEL|!gz)oNq@$jJAW z5#EimNsBC5{{rsN6GU~;Z*`H4kH~6(<2!fVF@{Ik%l7bKKEDGnEZNWPrYd{|vGPX8 zajJYml^53P?) zxMa>73mqH{!XD^NpGYuM8QWq8lU-YNwFD}$=&aI22tm0lk{g=>oW{KwagsU;3V6 zgJ%R8bs+xz0kjcm(9EnZ_Zq6%PlC!{Cdl~L2pj^9K#0I;j{qd&&VQ=SxC6GvrOQAj z!4eFS1VJhb#FTQ`Z2lB44PaVRx#Q&G&i2Ygk`M+>x)d_a#8`MPM zav~F$vh;^iRNOjJk^z>H?DgT*04GlE5o| z?4sGq%XAAsPZ>8B1-;>RIMBbpE7R>^rvfP@do-b(mySx%!i`PN#Nk?`=+W_x{RM|E zC88@RjgwFBjrGO%tQ+wyq)wx67yS>d`}(`@6oE!oo7$a`sKggl;qaq+d9sq^%sQx@ z)z(s9Lc>dXaS@>2Ci(5uEv{q@pg}*K01}p4;F_J+e~z4}R(@I#oGuQ>*8{bEDBS(9 z)VHbVyCsS$pCD9f750kU$@)3tGb472eJSOy5R*H_43SnhiY^7@Ii7!sO)_{rw|Np_ ziGyl2B`RO&L|3h+6tDSu@V%`xgtkyrybl>j3^uL1#aA%xz#HP99Rwduk5Y z9K%4FujJn=!@3R5X&a5FUg92D8JlE`3TdM+VNDHTuQJ#^sA-HI6Fk9Q^4sYVOBJm~ zIZx!}XRt%8j&ES%&|?R?6ubORlj>7j^RiVo>7zgUE46!(D=U1iV_Qv~kIK+g1P?~p zLoNwab0zBJtZbG5BTo1U{S7ghzc0dc8)<_eZXIZe1y&}FwK#+-V(m2R14*cI1*o;G z>}QL}HLhorX-Drr+s%UyV-Q>;fp_mZFXgWM2G(;MNgO!j*x$?z%0%=J?g+ndHXpA# zG+uWbIr8~o!-s($e5c_`*Jtu5ks_3FA108hmyL1=hAnrhOUyv;MwTpVikQ@n1g z3MY?HA6}=6oyGefdFC=?7Hm8{;M;$iQPtOA3#>aC+3E}UXO!y-Gb>g@tQh?9i;j)Z za~ZgfZb6DBr3h}~;e_MXMpTX)N9f)VVLEOTs@a(uT|yYNmOIQt&sp57x;fTOsyO&Q zks}sJP0u9jqs(BGS>uossh`n4J1HC9+i;n&9sL!yI@wrt?$-bK;x208cRw|Gd*rob ze(s~)$~dMQQqaOLk*~X0d5CXm$}Cj(sb{ekBfx1Xa?o=wvNLkVB&Rl$l&NHRqVodR zO@8AgliWs%t2n;5Dk|shO$-j6j~ZQN2DV49Wi03j7HjiCd6c(B(%&Qs5Gf zlw+UziHUH84@@vGI3r;bZm2i=^b;!giiJ1I_T-a~>Bbd(XeV@|_D*9_D+#ZWw(XWz z*x9$(Yow4Q^o_;ky7|@EGnRPbG1P$E*x1BW(f`X`H4_&E`~ zCHxJD`%6^s*|SZ*vvRbZb|#HSTA#$M^EwKX36A}zVu9IKnM$K0J9A{YtI=kwz)nG^ zdDX-!tZ893T{xF- z;y&5t7jNFCB$TK4)5Qa*yhGY$CvcX9-IzR7y`QcG*s?m8l)M`?Y%p*o@A*+M05+W?Q2uBYEqv@cpz{QU_nccOwm9fai^{Kt{ zQ+qkl#nIP-Hoq9v+-C6H${ka>*6kiQ_xIrRtM7z(U=x64-(D`#{mrSsB`YsM_`&Me zVPwHRH#b+RP7cDrAZze&lQDWcCxRYf*F@`PEcAj{ieoY6hn*lB%@?%ry&KYd+jBQZ z;D%}8bvgB7xexu0M`oOtL`SUqIVn}7T$*$c+1#sn{P7xUhHqf$)B1&`_^Nsmy1^HN zpC20P$jSivBaKBblcyMxr|uj+`E=>F-|6>D>(fv}Da5oCfs(eNqBcWx;rU#3u8x^B zaqn-KWQYE=R0*d~SbxOF?4+GZhN{)ItS8qijX2QULr1Iz*$%1Rt4rgCxI&@!<#9bI zs4p;>KvANOPa||&*EwXT3bScHwdPIcx)FHEE&K@hAQ9#@`-*nRGU?O-z;7E1Xq`Re8~sJ)gmZLS8YEvD6Kk~cX!yV zN^ivHttedQhrK`e#+?5~M0v``BL;LtfH~2=_LHeMmp?z-o-^^{xFoWh#26drpoJ7w z2%n^VKy42AxM+SrtT+yQz=x==PavkCve8 z*4=uZcxw9yVa;-m_B{D9ShrKua8yJ)a#Y7f|ZrG1o+;4HsuDK&KKDS<~W=zVvf)5 zCF{&SAgv=P-PIAZ43kLy9eS@!s;Zg9eB8 zxUj$xr-&Y^lUGq7YBI(A)_s=hV}WatIYd<3kHbvBrNg{z#E((5 z-u0fHSfTQMp+wx+;H;3U7yes1DrQPh-~5yRiT&>! z^*Rv3Fy%66E3y=rEpQx|9g{dWlz5^!-4`{({ko01>n_O)8~%r;VO1*$+107edq0Bk zHLc;ID2YR5|9XBd5x&Rrw9&5LSo9no@{Bu|Y_fxI@N22klOFW)M&Ur~> zp6xpOonW-pP8(IX-GZ`3F@LI))+Vhx4Hj^=7@J2FoS4bO=1Tg%-mJGi8@i!Di_&JD zDUBbptwVyI)|%?KvF&2Or#@ORR}iIl6|erd$lPMrK8xN$rG|=5W=mu#EN*rEy|$_S z&P1`jfsDBO8BPxVXQ+nhr$^Q)#7nr>k<9xQ1pKseb#R|yDg?~E!kcbLQ4y$7K>1ca z&jTd94*lU~uPc^!K`DCj&p)(o57i)e;!q6aE-)XICU4eIP4qoB@-L6>1l!CyQdj;$ zWYyY~i^AD;aj^)7oECxL83#g0nDto^!}yx!zGy=bFK_Dc&N?OUnajRW`Um&uR_kGE zC0}?3lu4=1kiKy*x?$lw0WkF;9n%OjUT(cUT;!0PChk{w@0@Zbns|6wNREl)Z`HWHaB1J4swfsD70imbj56nW+9 z)>Mj>B-xRKcHJPn;n@lrUkE&L)pD(6-05`bq7wE2qfNovDy>leDQ7(|*j#uA0jrz( z$rPW!(fEr=x675vxEosC7FESS|GTcY$P0tz$6i1EmY@43PY{aE>=zh2>9< zIu8-JxSQ?Pm~bEdiA_SuRpwmbI&9O?M3qt+9nDcLo%p7n@DLnaZAzFrXDe4vgGiK{ zf2mG+bWwO88$y3}cxl(nZF=WYPB=b!R`Qn3xbh7;N;6#_?X z($wu(3&rsrqN4<|{N)ld7TeY?XTRefu_?C}v))q^H(q~Y$beBJ$#RkSAufv}+)ovg zz5`3)t_NiTR8-9gH}Wj|26nFC_7D#vR;u|}f9Ty-)GCiMIrCADY|W18@u?xP2xL=nSRg3e`ZuF}8xIG>z(r{spovQ`)lAmuFdUS^#cNpo;sRDAhWsMv6&@h59NEaAyT8m|Rc%TE+lEfkRs>O=p3%X3!?u&zKRP{F97&1LfxBa z11b2Qj+>Fved(`q4bkra%v6ItYd{%Gko-LYqnjWVcj5j0cJIG*;74Jb zkHCB>=u9MntUb1llYjB$YtHvRX-EHv%+D|uOI2IKfV>_H=PqUGAuzMeQz~_&I7eX> zfu!1^vl#~s@vDixBTMP>Jg2bR0gG*R)w1IU;eR{e?0i^tI7e&R_~a(t=_}fp;+c68 zLG6|3IET*owxKO7vF&`mj!pEqKp}8Z9a^NKSl%LgqWb&JFl^6G@1kLKK2ch3b8Wr9 z2{c|9Ja*$pft%g#11{|=K6i|N)R!jtHW!>Zp?R=U(@CTIWds02{gy5L?%eX9Q5{?Ma-a&=8C zL&fHDKwd#~1!{5yo1U&isYBJUwEVL0HY6#l*(U|w^QC7!9YE@@js!)|f!txm1qUap z_F=*xa#a_SF-UCVlFQ4-Mzfg2z z6ZS$Sj}-1ZI1B4{CIfk*u^5rp^|UEKGWg#I8uD}zn6FH&kLUM&G;}pf#$V7Eh&331 z4%HjdU26J_5GJPHs6z0rnvdxgEA_oKH@6u1-HI%^ZahkMn>ZW24X@E!u=J|aJoZ<4 zpv$gg7Tpra6D&gr5e@I1S4T1%S}$_CIn!luddw~4cW5Q1&u}FaF!WQwc>sNO$5Q^k zi*`rC(V%}FXJhEaQG?}*uZUbsmq5Mh?yg+zSH05Jx)b-2{6Hq!lOIxQsxH8q+ZDif zODgwn?#>ks@~{N+C)-SqLGtbM?WplPvBPPZurDy1&@ZnA93vyE<&Rf=^s)Vc#sxiU zEWkj%l{Bw)vdnwJZ-8Qpvb04gC_TNP*gpyGyHYh^h(JB(?EyK7Fjq6kKrP5gn1yu3 zE^1c$5cNX4#dl6Fg;1#^Ciq_(yFO~A=m2lRY=;v_@(Ro!#A_pPNi+^8Y4Do1Ki}Gj zIaX;Rd83W ztIgo-YwYX)IznTsLB?q;@gJx19j(pSXo+-a2^NXNl0XXMevIRer|aR;ET8x7i2~F? zChKp?%fW?26TCrzTxohUS@n>C1d}N`GTV~_`CCGvLL4CXrf~Z|-31;vmGO?~V#A5W zaYF+}R|3o?M3Sj^RlZpI*w<-cvKHLd>1{t6_>%)T->JzuIKf`|k#V#> zHrXKS|5#Si_5(N3wC{mA)CXBf^?%&GOeKA{_D%=`nO9ma6Dl;)kr`-#-^S_r_Qhf_P zm@@h0iVMM7&o7-)U)*+}7*|=7JXqyg&%fkZ;mL$E#EL-sum3^76@+7v1ZyL;0Q~Cz zV&!Vwb|q=RXpEatgnu)A7ZDf1#csWwz0d9`W!(0Ls4}PVp_2~_?PrkFsPzLdcBy(D z>{TkkzZ1aCJi`r3tkSBiqf>Y3Jfmgsvl&AJWG&}bG|VED#@gN79BG@nH+nBz&(RV1 z@%^#QeRekPXbdhsv!F*4O?C;Sr~`=;%{0BelboG-J!Jjk;^WwtciHUjhqTwWTmwTi zT=?0GJDKW-3wvNN+UWF!g&S8c-FU$e;wR1w;a3LT1-wEZkrzCW z1i5}1N=@?`x2{;6a=NMLBT>HBl$2r7>7bk=b8hadbaB175NDm&ugpj;-zlMmsIslT zeUy?4mwg%7;CIX}$_L7f7q|P%bt)mWa&vRLIJqJPXyUlYqjJ;!Cg}hh#W?c#;>v>O z%-vFjCD$QTFWj|~%U$?lV%JNaXky^}+mj|0#`cjn;wV;v;wf0mE{LtGX&8ldk50AX zYT-W=9fzR9SH~&uIuxG!4~3^9Vf8h|9?QlEMYWHCG*f=1^m&x(!d(Y>lPbtpDhs(C z&j$8PkWxzoICSo*%Bg&6kT47@lg2EwA+`#PC@;7%>>&@AX7dh%S3_sFlG2M$zP6ab zgv$f4I@F0`lN)Y+-`n;Ja|*Z%@S<>t@SU(CkmAe*iMdJ$bN6$0ZZ`P-^`I&)7CPH^ z+p_#cQA50ETQ@Gs&VUuGI?2iH>{P(yferd5$3N8ETi_5L@;X`w&&id0!5lt`_YvE1 z`22UYP6IV#(Uxa5&iWGwp$#qBM>8-5Q2K44lIyp7V;4tMWcuA#=|VigpwQcJoCJ`@ z&dv_**Au6!>+X)yriA3A2``hf&sz?@fpE$EpopEJk5S+K;%aYM3DitgABjsu7&6=1 zPXD?F>D5qqCu0y{cwumiEk)<%)J7J8OJ|3wI_xL(^-X&%|ErhEuY3pth2nAog~>@c z)|5`yh#_lsE&fuOTXC$`Li4u{&?n$7#8_DNMU2fO9h}PEi;HK;GxCs1QxM6U$`~eaUt)qO1Q-|s)z|(+N)>_qh2TF6te8H(266ibOD}A` zj}%Nq|0VEBuUrsYnhiH2SdmZ&zt%s{m2|<4X$mSGR~zgG1wVj{{A&z~fW}}(V0ztE zi26U^uMXO@882R&0WBR&w;>l$MGkgx^n$?q^Z64tR_TdN3l6W^dTI&(zbr^2YO%Yw z{|j7Plwg-YPBHjFwDkYBO#ilY$uloVz6oDJ@k+(jTr}5LhA9!tO&;6>sg5bR6ez0fmhvj%<4J@C zD%Uc9vKr8@n%X{2Jholn!R}K+2DERydcEHa(;2P)#CUs(&#T;f#v>g$>7E?!dFtd> z|JFE!9h{EVz3pM%hv060`2KExEn8w{~Tu)Y8; z7;(P!eyA<~`G)7Ym){xI@pyG5zsh~bEY+puI+fF~0y`&LFpiJQq5|o*R#5Hb74}c> z-Kf#(KKAzm&vm!*OB#O*R(07j*7ufFC^ji~Yw?U0yu8#Phktmg1H&bAy!G6C5DEkdf{03l0)4KT5M(HxE;E#gB~IID98ND zb}tmJSu8;#q_~v!_IdW`wFknm&f|J*__0G(l8{5u zEecCr@5v4?;5fHqdCz!hd48_%D^ugiI8)>K`DOb@Z}N>EJ(BC{>ls{%W_J{Q(_r^5 z&1YSOORw%}2^B%5n2XkV7GkjhPMll3;J�@vL91U-67t?vuIgelw!q_9*bkS(ta! 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

Jp=jN-F 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 Bl0yIh delta 180568 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


MJ40b)!o$2+|)9=7vIN#+%EZ6i-#h>cmx@^24E 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

SkVI}nrUWEj^v%|ELuAli`|J^t`|=H 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


yI0MB->0faqw4JK_uLyQRfNi}ni-R2RN(^N zRo6c(N4>3o6h07>Un+=`Fh4%GnTlk((T#K-D5`nhk2WA$xK2%dR9_?-vY4 z)ymv2d{z$M-3yu>E^=i=A>KASFSmw&eIFVwTtJ4kkMXM$F;5H`V-%btRw5tSA0D&A zNmx%j2_G32P>Tv?hb}~Tv@bX2c1ldmo$Dy@8UMs}a{RfIaJcqZ;qb7@>9FvJ_p7+G za37q8j}DIVsq3D< z&4Qn8d6+}S4<0m@y0Yg#m_-axOx`rs@#?v0r)^?o2REu$6Wf9dE(B_ zq$|$$#HSskP2RY1L#MzLi&-W93?wwyzqgrz09_MNQ(a9xm*7qdZ3Y6*(nVY_^uR!^ zK?;rmi~z`KC{$c$APJ-Vx0VY=1fJQ(KfWY9_47M5^W(>KD0{A?jEv0irrV1IZ)Bwc zu7m=*3Eb;SB-}YJb$wfTOG&9Q`tnVOh0OO1kdbK=su1DZw%nM=xtKD@ zd`W=)!r)8!{-u$?=_oTby#g}I1fmDRd#LX3It|5T0%;YccrtDAXJX|2w@bBx@#Ka# znd5q^s2Rsf_sz++ZH{qUe{qn9Q(%33oSqJ4m%sMCL?bLdeUkgx(}pxIt{bmzD-+W< zr|pN#6W_bli88-vn0F!bwJ_>XIKLzXTek+|-7lMug<+*Xyk5moJ$v(-s^Iz?=O?{( zc2Y|6g0kvQ($jH&{P21^M=nV$J;xr@iFZeyNH*Zq;2?9czyKp--Wfmodd3RlTb+-U za(w&j{3|MGt!!~m?~2YBBO+qAE()V&xQw(!(Whwj%IK%0xZV3LYAnB4fRXV%N-4(c zgqP}B)Scm3%G*Uw(7ZA2Lzm^(Y+K(&8|x4LB{{g6uPfH}9$G%gkc#HI_~5JOnx~JY zb?(`?slLLE?h!To0HxWJhpEALCXhr$*YjZ=4}xmO!@}ZE1>;>FLsW21OI;_fnt0s* zS>L0E7SUHar$K3(m-jS7Vm3DXF-ONUrVC06w3zjiw4)^tIB&hKV7m;z zHKLPM%*m#C!}1GB7zbua%q%M{FC|_&sitUIs?%uKwdvvKO+UK8AiM~p#!OcVe=|O+ zUa)*W?E10B8NV|MRp$^a3&!M|DpGoP#MsL08`W77FfdMi63UTN@;a|1AAwO{mPh46r(MX9}aNzY!!JWWB;B7?M})e4s9@?GU+ zaqJ-j`HTs7Yir8`8ct!LpkNoYn$~mL`o3sn1}Li`eP_blnr+>OyHi0V9cjV}JTO_H z6V2p0JbaCy-M3QNH*0De1OV4_x)ZA*!n)`y&x2kjeb3pL-LUZw=J9mj)0GX_O zA9jO6cjTCUsb|*^ME58dlv?ANk5T2v`->R#A1Gou^#boFdSV0HBlqZ$IMhQE)fm!k zSATDdYy-W*p-1k*)VTX3e4iEo$dWbK(mveG8+!Ua(F+a z#V*b#o+mV-o)(NIWpC80?&END60%ZwnSV69s~4AVF*}6#(#1AmOKE@w-dE7JpcfXP zNtGs9ukLBJw6*nvfl_JbVI@~$eWCRB-fZAu(8U!U8Vg}L3t~Ae4J+lyh}#UQL?FV5 zf5nkeqCBnsBtwn<>kFdTCZfC{vVue7`EX|VG)%P`-gC6=gn4b4V6VYJVoK1qk3UG# zU`1ZQt0*5OwrWGwfR|HAt7m}Du$PIVM~6u!CC~Tp86^$^R2k7Y1yT| zxA#dBqC>m;^C6C>o79zlN={jIS+^eI@&ue8IA5-C`A=AaYRY3_-Y~b-Xi?UH^p2d) zYx9T09*kzNy|tKiZO7s>Um7jA=*ODsFsyi@B=s1DA)a)$aLIpZ6Y^>E!KWWDHTH6K ztbfSMJ|xZ9HhO=B5hvov-2&*fO9#~-IcXQ{8Lf*ICnStzWRDina%GEYgU`-*5Kv6! z=H}nfEkJ+1Iw2sJ@8yh~R+0r(jBm0)=p~YTT}^ z+H*6x#}YOd3I+P*BAPZteFJ2&1>j{zaD>yKge^F^o4Gf9$sAgoa=5kzu1M;oMZC}U zSCDN?x(>h-*4qev2VE6_I7z@Ba%=akkl<7bSAz++QQMCl9scrZ4WM8FG%tceBbl%e zbT~QM0hot@oBJ#nf)yv4oiXRLAnroL03O>BeqfbsMoT%>9t|0|yS_fyY$j7}e(*ja z5KWz+)@Eak8sUte*z=ZV-`^mQEAJHkn$WiFH^evKiN*vAkN2|zD`T>X z>f&CD+6QS>w#4{1zG*6n-4+SVxU3XzzlH$50$y05$vrJ$jC1lIZu6HOt0knDuQSY6~0 zrX*%r2X55>LHjI1a6OTuqfkO6ozJFCx9w(IZ{a59=oL*Xcyfvb`=wu6uxhZq;w!A8 zRv@mTy?WcnwRw!nkNz~Rz&&4 zqCa?OIeen?(AeAdcJm?wW$>5^j)oQI=_$#$n>osOFXY&e)iGXoo)-s3FTY2P@w~Gz z{*_6_xLQ=lx%74N=l!DMlmajArNPpAeo~l&cJCRxQ!XmamL|c|85l0i>yV?HKzkSK z@^Fm>jrmuc60M-(xIPCp6=7(LD>UYS`RHiT$P7SQlddZFRYUUg=cQ?8W*-#v zrz@rV_5Dl|$pj(_s0y`AjZ1lE10OU!4w3b?0?<$S1q3i8jS*D$PcNctT>v4)8a)zT zso62SzBlu{d}~n8zg4yW!hW{%?S!}fx>-#mZ3(2s`cA?<8bxQnwpdt>Y5VqPU$=DK zH%w@H5k+`MUT}$fT&jM1fdbvE!4!Qd&6MyNpHl)}Jo2Jre038$!w7bKgq9dfB8e z6Fu>msRqL^^2y76N>)uP))#CD{BQo_+}7~Kgk4AnECg6e1aYHBvF>hTiS&=D1l8S2 z3)i9LrfCZkRK<8aI(tUnzr#dk?_CKiE0ynIA!D*eX2JSF-l^H>(Tkcgp36|Z(=81s5Wva1$ za})WtrRX5Mbg-)NqDM^=BdH7j?smh-2tiyrVIhav2>*$2Oec_ey z6N~&@%0iANlcLm|^>21QEU8UF^r35BOuG*aJ(yrp(VUi&t%AdM;Uo>=TkBorzaesx zRzmU#;vrV9D#BWoHVy0N(zI9eBh604#ksR{I@KN&t9|HS^SXYlko^1?v7FpDUU8I~ zHR4Jvi!oSw??hm?+On*8+aoiw;RSYy7S+ccKYGdZ!PNAW^IHdQX?7iiUerl``LD>U6y%H0bD4#L#n!LR(htH!En znJcyy-4fA|_$D;LSJ3LHR!q!r;kh2P?R%lI^tF&yJnw=Zy>x6msYle8RyyRR$!O&{ zOQEvBFz(wvnf;WkFFw?dFS%t^Xxom>XdqM-6}g`###K_MYCfUnVp4G#}5HNpqFCBb3= zP4GePerg@@$OqmnTc_YuA;%10pRSz(GI6#z$J=%<$tS>t8_TlHX`&x*#TD?ic zbi+?8D}bW|tRcSlM1(>eP~CP=q)=Q^GKKc6Uu_~XxkTfj>;&S=HCXO7%x#*W!%HCR zFlXTh_@?st)Tgj{SLj?4B91JuNZ*H+kN~-opzB4VqDjXk&#PZ=&T;O25d*W!-_{zD zw3dM|#RlvC)ZZ;%4X&tsS`@B_TY zHoRoG@`vcs*c#)l*TRZ7y70UwXfM#+(+iLe>1Aw#DP+2cyjfMNbtHu7?;CI1gnph< zn%vY{T91x_uaTCI|GNbx^O-h#mylp0TX1TyKxNv?D`KLihe|P&U~w%oa!=DYy_lL*$Wivweg35N zjS~L=vf@u4>dSqS%E};c^#W}f72!Y( zm2#URHeXF@`+RR%7&UO|whyi!>W;uz7`%O7JE-RMUT==zz`M6ZvCNo;?L1>d;uA&m*6o|ywSv+Ro7IS zq$R1rdg|mD?q?viPxc9=*3ntW>x@jwFN-{5I&&&CkPa(dH~bS#T(?fq1sHvW$hgNn z?^T*J9LbV}-gK*JlJGSvZe50VMqZ=rbk!U#-l&7*m46t$yJ=ABpe0-%Yyaw~zcPEB zJotq|JVYsNA}QEZ2({{Vd*F)T(h*qDaM(UxIL?toQ+$GYhu^tA$nMOsCLC=ps8qvKsDiB!0A zhYX14vnY>mzBnM8o$Bam2Cja6e-X)JgwV2$)|(@@d>Ri5HaM3&39&J2cc<~u6$E>G zd+34;Ko1G9Wp25Cy$f8fAuw`iBo*q4Jbv{kR8^PXgW9ia-@~q=gJY-xxCC`|5P&*q zw22@6>Vy8L=+LCHUcL1-9*yFx z-&4HdUs8grTyj9)!3WMj#GiLzuP-as|pp(c{v$;Qp zr|{Q5$Aeyj^_Gf6x`RC^^3y3Ds||0NSkm&7lkNSO7$_fy;^9Y`5F3uL(yOXXV9sRZzH#(SAN)~F;tdYE0jvgqP--@%&tt7b8I%8OO zuHaz%-3#(Dug!2DQbv*!R-0W&K-X9xPQ^b^1;>L zzY1YEC26IEw{o(X5E2m(oY6IY5_?zir=Zn%oF`H#L>!KdnfP$2r@Tc$mS0V5Ygd|S zajOP7Hl?xAgYI+D@nPO`W3)ribO$@td$pFV_1dgz{mD^HZUL{|Itj_;H>_t)x!kxx zp`{gAWnXu>XB{s3b=}0C0yVG8nO`CX-Pk7O@}?Qf&k3=^D8#Ape@xO1>A=CF70!Qd z3Z80uX2a9WiBSIXBH-j~Svsk@k_+ud2oWZv_DG9>ozpi(Z_YeOXXCwmPM}tr1 z8o|?Xom`m57et3?h>GuWcx>hDsA=j2jh~CLysAe^!$_ij>$dV0ircrJRL4{6dv94N z-cSkTx^m@%`eMY^xY1#E%BI)dmpuT=*py7azHcNsJ5e)S;LHM%!FLY_k zUlbIP+Q%*xSQ7chmjNKz~H4tadBy*scldOaY~Qn{&Zn*@P2&9 zEF_`ilnO~6WWM9#et_C%MoLRxn-rADkjwtQs9CqFsm*pCX2=Ui_s;E+p+;zkAVjvi z;ny{{__Tw8Vd0LHC`+0_L@D%sNc7s=y;(l17T&aS;9__o(~$yMb}F_2qlTtg$k9q= z>ag6W6u4c2lYsjJuqE2?nwER`hePPG6IfGr1|^Uzs-=Iaa#+)?rd zezs%}Dh?`F$fDd0hc1F0qEY(q;oVh4sJ~>;E=Ck52DA2Y4ixWH*?(>BlJpPB_kEW!!I9v5qks<*(U7&`4^16j z^l6%vgsT_PNAol-f#^|iGx!mII#t!QkmD-wL5;Y6#9L`s3aX_Fiae?>7MO{`+yDK> zBrRMzM9V$GUwz4bkoF)c9B*(tL}Pb;*oH(o0m4H<^XcEa7}ve?l8l7#R6H>Z-k-5X zU*bY&q$^p^rSM3Y>VK}_&ujSmIzmRlGO|HbDj@LxHJyH6Cf9uYbD9v2H&0sMD#(VA zb_Tn)2{x1xmLSN^R?3Ec{uYe23#;+pPD6c3dREe6=bZT6z>*r8=r+t7(F_qEiAe}G zQdycW zv|~kC{+NN@+t3L?1DYYIvAr4#p6{Mn_nLC6n0m>?Usl>^&M%$Uw>6ww_B|N?< z5f&gw^rN4QEoIB1`)aXREi8c1NLE!^P(Qz*{>GBFprBNLvUmF0Nt)by6!$c~6j$D(!Da-HB&o7$NGoP&5yTdA)arhu7s87qE!_$!( zn&&7HkgP@Oh<@e0AsbXt9dA=UW;aYDrtrcnA+7CRS^Y#~#0>_K>dVVjgrzSz^F?jM zKACnUxn29~{VtsPD&FyplJ^Mv)Ghy>w`X<0{WYVy$U^~BwG?a zRz;TvUPRAdgMm}l{eZhHI4V_>qxRB5$97pwppLrjBZOyyt{v8vWhHHwkdbFr8ZErg z8^@Ou>C6b#JGhFqIF06frbB3N_{ry}{Ek56b8a_r-avUpDB@7{tkmY3(X~JDZLBDY z1jvWdL$*z@JpMciCssl%nu_wAk(Z3De~|9w*7T#Fj-tsg;|^Gr7*d;wFGu27K|t0+ zpW2gg$@9AJ>_nKc`-IlUK7Hk<@4y-(J(A3oZju|cyN%S&$G<$1@1f%`2FjQ-?TlD2 z&$1z@mvaly-gka;FIj_?6pb_2sk4zwDP)MAj@x9JcT|268EhtzLCQ#?8%Z{v5Um;gue(OdK}EEZhkiqp+Dn0$!JSu_d3{; z=U?-qN?pjDD#fY6sQPj?AN+$qx-HJ1IIj{NzxF&bp_{tr|16l5>2FI)QUIYQ>knM$ zi-V1}IH|+vC!eMsVpGcE)C7;~gUhgt{oNO*;I|P8K}VxVf(!q?NY`FdAU%n?`QH{l zwIKri;{4G^=!>(527Z2j$JKz%zl5TDM~*ZKzjnvw9;?u#qi%)+7BH| zW9LNl%uj?VwH2Nv>6!X%eSh0NvHx)i40Z|6A?4-)D%iB^HB@~t3FF$Bb63FippMx_ zD8yQ?$2?2j?>T+Z8!_s~BvChVtgr~Ha-*a5&f@HWHk?~;t`rf_Asa21oX3tg>JJ01 zIrj#H5HT{vb_Ap_sy1V8USJj>PnBXf`z*6-{l|CbU2;FC11oE=tD4#H@>G z^v^*>ITSfMjzx;9Maaqa552&{ua66l4^{IfKTP@P{J1Mm;l zm1)FjZpOB3JP^H~1|4$93+q|v2S9{^@`?-S{dmwF-1GB^g(f^0Ki_jUuKSxxfF>pA z0x77FL@WP*0~r5HCB!=u!C?Z4XwVrRTGSdSqV$}cgnv^B&^qKhTIu;{MnV#RIIU;v za(JX)&abv9p=p`ZPA+XH({bM$clFqiIBv_0&CWMj$Qht;GQm=*b)&1*e}HeJ?Q%@Z zVPw{JS4hLGIXz}rV+Ak!WR+6%%RwFG3j+;bqj4>Nv4Pvhb?bFGjag4Q3?j(v4z$nT zdRMehkgs{SIDK*=8T-IHsQL;Q?0*lhl!b-8q7AJV?CJZYq>Tx z)6VnurSWhS%XDAY%hR7eb-0@??8P=c)AfrsX`9ZiIw2|>9sj<2uL!G+S^{H7Y>gE( zsX3$8?kE=NIDM2<0r_59{i64RGe1Ij^(#e(mSk z&qEU$$JX*0(fdU2fHQZl@CvNZW{lbZ^6V)16wiO*tyJ5uHhuFZ+wzOQuMfv(=Hw73 zm7JPRP)Lcz`c_dg!rh4?hZG#w$d|v>`g(lzMUGMlEbhCQ$USVT+)*!bQeP?OfIa=} zdjl=x;s5+Nw6*K261#+(o89x7XqOB`Q!sUePQ3+@zna6x6C)N2qnJ*|6-*Q`@uer4;mzK|$lsz_mp}0ynB>m=?vt%y(T5y&wlIOy2pZ|ttO&$XgC9t@|Vs(eO#g8b=w9=I&%ADy^%C<^Nr(h`Ba2K>T|+)VR4%pLpT-a|S~c z%jR+W*sH}>Bo8oepkLYgbFxxV4^`)ujggpX*{7Za7;j`oqu4x}q1i8J=jemCetX=^5xe=> zY9M>N=W4Q>fcaX9@2bXy)^wNMqIT*Qw=u)uQ1*5bjl#WB)Nz{d1F;uYQglKrKM#fkY{pgy`%l+jF!;d0D$%-y+VWl zwU-1fUaa~HcAycfHh`51k&j}S|DI@n0fq$&zy#TkEijIqX$oTOcFxfL(V36W^J9d= z5ZRHJXT$-`(u3?6#YQ)&vW*X#Mdx-g)m$q?WwMM^+_@}lH zYHe+O8pu+EtS=yq&&FTJp<~|~&pwp#4!PiBY$?e!Up{BF+BRow*^~o32ArQ=F*^0( zi*&mXD;hP&h?078yTitzHk93b1Ht>+xLM!8fs+2=syu z&o4dlccGn}ngW=u5GmOkAAg=Tc#1@iw=9rbr)sB zMK1Vw+pbrc0~9_>k=x$zpZ4iS9WGnm(0T!2MD9!dWxXk7u$^2wtv*ALj%=TM4L6H z7xJslX-lW39R=v0#_v!B5zEVtWD9Q(Ekli&H*{55YTO@I&KMbZBWeWfHK@@i?T@y* zdS$v38gA3nJ6@WsIKM=5jwB$9;?=2HIfMb)Mz9y=j3m^kiU4@%G>@k`n|R0x__QI( z1bJ@NG+=Ix`X2abXqC-F)w=K}eqlxh#42^g1Y2T{sN8G-wwXXwvh?s0Y<%55NfhDs zLHyfgH}RfkpFX3Y69)ZpgrRfqrz6Xb(Z*e2xLuG@22pp(#NbjRzqr<~-&wRRqo$cQ z;kR8J3b@qjJSxtDXFLy3yhodXNY$!czBBU^ofS70aA@p>LNXEElIJQVg-2!No@ePa zMeC8xV4&ak#dB45+Woxa`o;U}%PM{ueY2x= zmIDuT*hr=!%sEh+dkn^0sl66%V2Np&d3j+v{+w7`Bq@m!$eW#QI6y7HUaH7ujGaxl z_#$2)bbq@9v~oWdO>MXXOCB1K*Raf-#aMl;d=d@}E9H`FR7c@4?SwCOwk=hA^g9&) z4#$maM$2JiCfa3hL`QBK*pp)n5sgW6X)gv2(U?_s{f)-$2fRTGIWU+f9@d>DgI?db`>uzjf@MCy?VVBSIHFK7i?32p)J+B_Qhus3WnbQ86*dz zP}`m&jzY;mW7Kd-Iq4BH!Z4Q9Z|4ph3F~HqM=zSsL5NzHlN9b-O$Lx2FlfCVj;Xof zv0;uIOS!JAOtN?pvmN6+_eS zJ!TxO;}-icHMp_vq-g)si*#0+Kjnr@nvyz>KvK-KE0pm|t}ukScZ;LG4KO*aR>|UR zsv4B~;!`RUZ`Sx-D5anW-{Lwhz5nXG{>)hLsjN8J(L_-O`Ox?9AK{`sOa+#&x)z%v zF);|VFaz|f<(w+k*thPMEK$#o!omGQ6xmt=ZR_+DHa$1xtH9GDUS!hNr0QlD6vu-GSfT|n=xelLV>4fqEY`O6dy?_4q6- zfi(^~cr@9%&36*Yyv;LR(+9om;u?^H7M7PG5As)_Wp@QoF~rB?rd@w_?DVCN_V)*5 zsb}EHc(s7ZV@VquU}k0dJejTQGmq6x7`B$f)`Oc>%hXuCl=%2~r}r`M@Zh;T^vVKi zf$2HxOg-u*`<&Olt8o8%6{pufywn=UYA1lJo9@YHMJ3gKw)VXK&PG9SUd_BoGs}Uqg zK20&8uS}+=6>^B#+hR{nfP@Uy;t(1d>%bPtevHq->03jtGY9vhK_9lgy%45&Y| z*qMfaa2MbxukD3V>+dZc@>Yt_Afq)}z&Tp`i`6JS*2%h)oXE{F$6`EueDhI#vA+gG zp*!Pz8vjZLsaYNmgamX~z2Bt-fP(86({of1i^$Fvq^VO@PoR{CX7o;CCqB@s_{KSQ zxlCxkaeoBboqKhBjz4y3Gn3LNAzT=O2-qt(?JDUxIzxuGdxwTx=>KJOK_2I`){h8W!c?n&&8ua_gOn+K4KQ z2g4U%uRHctOFs&sq2WV7W&xg?&_%E<0HNnqn~t>fCE!$5Np^a$L8wp7QR8vCoXyO_ z65{U5L#M7U{@$H|=dWXD&YF$`Pc(o$5+lICh zcIIiT$b8;>H|_(-ioA5I(UGrMbnIwwuKnT~1aGE@4z@ ztl5HIryKi)oi6v{1QepijbDHjwU!)`&3EL>tw5kOUX?)}mG6Bmq9-}z?O{?_H;*Wf z)D}MM_F|%{p~YJ|FDZAjy7S@mBPYK*`IU=)xkoGB3oj|4Upq(JD}=0tXB+VX{;sY= zICn@ttExM^ZATEg+G}c*UHZN4eID^uQLXQoJ@wP4d@0jVQS4bd_saQJ+X{olRk>fA z3chdBX=Ub#2{qmxIjnKq+OW7FG48$2Q8awIzZc$&{R5UFwm6p|>T3L$qH*=1a!c$Y zKXf>bZlTwgLMuHvxk~$fpS)GC-2%>a+e|vE(x-|0tg@cKLDWnBhhL^ct6k;5bI#|7 zn=_U+Hc}w<02Y=A$ZlX`FX|{jX=!iY2Gm#_fw>TXR5|`Y@rzh_3?RD)D(B5=Bs*52 z=hCH1&)UU+!VS2$07Zphz2^}TY=+gh!FCp~ohz3TY_L*j(}1veG_&O&3?xwTmiR*W zz4ER9eSC3!XKh() z|4@lm;cbAatfgZs9I5Ipc}c-v^Hqs`W?MvJ(kBlF&?Uu%`<7n>I|WOdM~LtV7cTU0 z6sPloWAxVKwLGmrE7C8a!y31~0FFarvb%*!Jgm+rSP-hBAqvK|vojaj{~7fvZrD|a zbIakgW1FXe<4yr6qX84xY4Cmfi_WT{Z~S*?J9OT0wI{#ds>g7>^j%JVbOkSv`tZ!J z2_$tw$h_xRxL4eP03_>=adCnB`#iPB2dCTKbvh;PqO<%sC!l z_wu1@$sKB`yk_?lzbB_^c$@q~CWsiZ88uxkF5uc|cEN$g8G7HR_TFc{O|oFR@N*t^ zy`1_~kqxE%U_YOOAeR|6$8+Ug?waD&w!yW~*0kodmc>!k>)U4IEJm@(3d z@?LR(im!ko*apgP6NbNq?(0}Ywa0yHU2jy}$_?yeG)0UG)uMKQdAU-e&`ldYEkw?i zJ0gV{ofyq7x+N#Fdq&LEtivBDqw-l9P35!6b2UqOEctLO)qjev^a_8$}luxA7wexMgK>$}(U zhoZ0pD5)$TsA!egH!Po)KRn67>Cnwu^FXe&+tP;NTOyg|kfp(z*cM7#!}5`U}q zD(ti8=&Ar>UtLvXUx(7I+mnv*uH$ayE!Yu$R2-W(K9b+|vHie+z|Jr;r#>4e*}iBv zW{@xf2pr=!)E}6OoUb>0ivJY8R83V_rkRrNkFPAc_(Y_58-Cw(7>Uv;Kf<*guOxpf z72dRh=#}s{A~J%YV{%x)<8O^N7=H?m`ZI>7@n)VY0~wCUo-tWbW=K+!de(E1T;yo` zmRt`-h%9=C2NV*c?@md9!r^`iIELHgC* zdgcj~qB7@oLHk0HiK5~ zEanu=#|hp3QhGhZVE+NAX0Lj?MIagqe_eQ)j@(I{5^pOhD3Ao(G!)XjsdmwxCl~%U zDuVTgxRDMyw@jUK7=1xsV_TjZlPJoG(r0t;XCL41n72bP7{S|jaWr+8G5#K++~Hni z5s*m51^zw}oPqkGw0mRkv`Lj)TKdod-9^Nv^MtHU?KH~oDuoc5otxoa*vKP z$^@3XO$y6vYD5O9@~Ka|TpafEXJZs@IQZ^AB2dbEm(4t|ZwQaCLY$yXj1|BNudKJcx;?`X`a18#O)2WMr21~w~^ z)V9ZF!r0sV@?{#Jn-D!Js3BKpNPb_lS<5>xPgSn7gULBD#%lK>%xNI&eXwsY2$d9c$Ty0H1Njl-( zyA=2Kku0#`d)|C)gp4om0yJbXX41SPG|eMQKk)6w4OCSXl>GLrmhywpfos~!t4%+$ zNKMzGOE{6?0CJx+2}3EnuAENM^hx-;bGLojqa17(c~fFh8(z}K)I!xD4h}*!s2OI3}_!jZO`jnAb=TA&i?;IX8BKw%HIr_ z8}t{!ds+711Y`~9)SBi&O3NQk%OA>$_lsrd-@^BQe)8P`AwNaWC+i0({r`eNe_&6V zT8RJR1QUP_sQ*Di{z46|dC%Mpn5PJ~1(r}$jsE}#DY)bRc}@32QZ7e~CH}#u!rBq9 zm`>WwUy#2is^x;7xi@9Vh1r0~ZQOX3Pk55Tn82Q z_`4au-jd2=Nia+UPh!-rRCFc(b-=ETAcd^;V)g`?7l(Gy1mt2pg_Xb}f#>6KY0Fo& zpN6BYk>Z*P^wT%7pTxGKe0^o9xF!LPeOfCo9}vxdaw`UWGcx+GgVD*Wy4z5?U!K0h zeA&>S7=U3xDu(_1l7OorVWfFqgTl1Gcy>W0UF}juFcyvRl#3)*NN2IsE!fRqxpGtO zgaL@%T)c5jh$=x}|JEP4%F5mj7y$5GmyhR%n!oZ>`=WHplZx=hGk8u`fvkeFbHUx9 zrgGG>3U1ORLIeetCbPigd6iklk8;m~+d47X3D`SX-pjUfZmT+v|A052RM{r?Bw8-z z-etK#>u*|F8#2m@4lI*c>k?jMS=TX2hY{;S}&)t7;prq1<}jN zR%DT-K>&lyBeB-JX`M***SE6l+#jbZhd7+7=LE{ch2lL8vB`b%`jNJW9w0e2_!&3;_@$l*!IKmeVctB2Bo7_{jCQD~~> zJh9XO05Vs&Ht^(uz@jmT1R!!SN$7CPOf?1}5j6<6g8=!=X{ z7q=}DK48@@>@+STUM~JY#&2Me@fd=Wv^4gp54fTfftws?Pj#hW}TCtg$jZ@!Wi;6;~*m+yx4O=-dSSzf>=C1 z>5YMJ8M4bSEk4DjKBRpBg=EZ%pl{j>Y9Ia+Y3iLl&DgR%jlnL9QnbkLdnj_pS1>&sR0%x)o zp}K-Xzl?F=!nl{814Yo^)D=3(UC#&MmViX_hq|Hx*egwzocqQA>c1xsG?}0qRK;Mn z56tI)5Z-WRWfu&hc{|c%nf3-%Wc9S*gnbDhN3@kwEf!8t73xgQyVu+zr@8U08wm7$ z=m5@f@&dc>OgPEII9E`j<;ab+g(eC=z8We!62hxORJRE(MJv$XrkuW?A{9Nh90*cR zJMwLheQ<~68>0+lkzp-(HvfrH1c3MoV$@%R6$Gkz!x8?~w=sE$38OZWiW>!wHOzJB!=Bz)wl$;TQoS9x>`u8h^ z_bz}Ar44c%%R8yiv>8h+ugBr5sgu>sb=eVcc*-0{L5U-BM@Fze<9Sf?%TE$tlIdkt&;N^28kH5Wz?4gx9c zV$I0460dn1_Q|@w0~81EWTFjxuL@;Z^i$sSAAj*o9**3u0L6i8h1{p2@btPDte*I;@*dw=5oQz?<&r&4}7KX;=U+(`Hb3X%CQR& zMy~u*CcX3i{ZJgRHr`q*l9!|<*_c?|>vTS)q})z_x2Shrx(QEi3uiEDre}1H z=Dy<1V&<+OrJsj;A|i3|k6e~0eK${&L?5}}!H9m{p@3Z`I=%vHx-X6s&>;`I>@DdG zvs{6Gs{HjjK9}H?uT9yT&4?yyVwf!J?k(7BDgO(N-##Z@3{X?R^GhE)Gr4yerb#P~ zb5CG_F(4=$97VNf%K#S^@(B*RP6>V^1bYxkLi5J z71MCbaH1J8U4|DB_+Rf2Tin)s^6%5+dHGcNLL*MhwTt9*7bKo9Fv2bdpIwA8Fyj1m zxHng#qF@xcku?LnRjgFqf-uKYaB*P9P+QrI+Tm&HVf$$c2I5H1)d$ru4<%8@!aW? zZc7UbD`2AL+1}EsH&BpxJn$5TdNW}=3Pu%J*;rXv)POG%FF=|x^~u5>U|4BFHros4 z_{42vT33;TBOisVJv!T~K8sYeJ{N+m@w+gwa8W>E3U<>^u3ULPB4#l`N*Du=%XqIM zk}8+O3(gA0bB`fP9t?>g<&&iwFU zPs0|N%pI7na^ZDe_{mv*6$bmYLJBTEsv|AYp`k=(d40A_u^l*U{Gfx~V=^z%by@a@ zG*VsA=u?Mex6$~gPcJaF*z(y7q)3|`*v(Y)KFtH4w585tAdHntJX3)2tD9~RJy}Ac z#%LOls~xx84)ZXhk5ojy`}sVgljnHqr`Aw_*6bpI@@wqt^Om{6-*d)6isqk4-UBO)atvjD{q;>v5D>kEj8wh*&zi$x`_^1Lq=WveYV%h-%Vi ze?>StF%jt?>+72Yl*-S>*f4tT3JS%sY`Q5x(=|)Cga@@XXMC66em|~2ze0IA$t4_| zO*Sx!I6*|r<(zpSXX_GyAx)UIq3O?$gr$!2kChVnl&q|jQ2kQGS`!>FH*L6>9}n;a z!5jdKAhR;PGM8mY8B>pgi)T5QCXe12$}VU3n)yV%$GN8-c4n;bO{1=FyIi5boL9oErxAa*Rlc;3lMYDAeElFw~* zP#S~^QUgvMV3Gyi>pTxgTnhu$VI^RrWm#t6(2LIboBA{3GSIF}=U zT8!o9bQO9746I6UadDj-A%2&21I_JH-(%-6GOlK*`*&+73aFl2VbJFaN=hTi9$IHh zeZyJmZX0D|)A5SmQCaQ85cB9uI3~@}I>S=a;vajquO;6{sw8^oLUkyY|g^oc5tSMZD*a zt-M{@uAc8sS-Y(b%YzRmmA&CavlvwzXQVybIkwb!cGWXUyPByaF@f2M+j>GsPy3szl?sl?ati8dSJZf*2ygT zl9SA}=@KcExVSNJgBk-LR!kfSr?(hc`1rK0T)9$x^jq;I5A_H4g0Y-L2Xw^)+B|RQ zU}Bk0-n>K}FHq9G#?Zb(pGX#nDXYhq-QB8nagE;qPlExN0;AyHl$4g1W)>9GjSv%h z21J(ofq`50OrO4em8O-I6;SXWE;J#vMy<`!$%m3WjBJ_j2g{f)XC%9{ln1pj)d$Uj z`w$&FI*hSs34*MB!$RI<8^fURTENG%IX^*77McWxgoKm=HihWHxGDNd7d8+AZiLWg zCE{!ui?Udc=EehkAvd5qVg;txN9t#R!H-$V#6B2$W98;n13_)zVa0UH;T$Bs;D;MP zJKvUw&MP(Vd0C|6ip;C90??jbg?(}?RQ2@IrDF=eWkaY~FHb>1Ves=QP5&T$XxFqz z{mvXl3rvijPZ_i8GX%GM8}OJ(|M^pn*J|*IMUvC^2H)eju7$!Ww=vN}Ch(>Y^-B9= zw8I81;Jt)8pe(pgXQQkY++iEpwLzfsC8o;wO4v;^?)bCQ$S8H9Sb8mtsW40xp8oxN zrzH;yEo2&z#(r|V9OJ#yN?PW;r~rUC1r~JiI#9EY{_=%AwDo3Tek3C&kYZVaQi|=S z5U!^i9+p4e%{x~a2rYd!-9MPxijE)t5E8{c_dk%Uqb z1=C?KT|q@f8w{|$Z9f2k9ISfZ;=+B1mqksp!Fsod3MQ)s9GKiDtuj_i`oz>hE*}A= zeqj{63P6bl4o)Wn2(iR?Sr^x4?|!QVZjQ{t!umTqJ7_;KxC+Pyy(TDJ*xKub=-cFB zGT5{SlrvPLzAJToh!{-dkE7d|E@~0?A~OqPQE%@}7LEq88;v^#kW8D=;?mMu+AWps zt-WJkp^g1VXH$#Fi~XFGMI9a4bo3ca{EV{H5$)oV@$pp{30-G_L_C=6@Mxa?BFY=A z0vo&ON=ZQUtpEPhOHD^gKn+Bg*setafy+r;2s<#0NPxYbt~e+i zNGkl?a7R`_%U?!bUm^sTdT3piW!9xNS?3cmZC?R>t+t!ugFsasLD#WJ;Cm~Zo0+-8 z%;;25EqjeLpxW-oyBi7o4qx<2Ex!S-o!r2{0EK|#SKtni4#cOBAV^)CC<_-o&?&Q< z(gIWagu2NvRc)Y2B?U4?kACUL9<9tea&j}MO;*@r{UaT(q6)E&teRSJG10VKfn1l? zL+qtJJl^f<=cAUW)6OYy8+!s02^05V_FK3=TgFgU^Xm(K+!nFQE7Q_f-iI~Mh`=&6 z!E>o9DftQ*_wcx{;{s`txXCh`jIg`>9oA*m+rJ=YF0N+|wL3wWEA1euGYbhpdRUXD zb@v+VuYjN?CAP$E(o8F~*InY;K~LIcU(sdDhXg5iw9rIqq}1vbuYFQBHn3woZJwXv z5m9E2kCTB1_Q}MCyP28US`&Wz>a+_!sK-F+x57SJ02EySnF8okSoS7OjBG-4_2<#a z$$7vi=qD0Ft%0X?ESRSE>LyJI-d~^X(f0faC;&=9(E@6Kr;)DUfnNn81*#yh3VC8;6!m!-6X^_7(x5WJJP~A$k$Pj~Q!4nQlB9*@ESDqXoGq6${xmzC|^=a7K=bQH=F@spy zfI%zni5>qi_Fsy(=s@nREyfwC0f~9=M)l_hck@_K_vK*M)RW_rK7c3)GX6mL#?*fQ z=Ng{XPxvw8nV<4&4}u)|f$>>P({okep*CD#Y+PpJQ9ErP0nPeV9Cf_~%Wo80@&M`3 zEZGQZPyuO1P>a&t!8oF^+1R|tnhH8|S{urU066KLyLWx$&VeCHikh05Tg$aEB$yf2 zR8R;hx1Goq_2DiB$s4skq24EYXwb;l_T$G2kfIq$;BCl}3O>ND2{2T;4=mFZBA+rq zQP{EKy7Pmp;BhhN6BE`1)S4(T>|2Ju2K1XyU?d+IZ}CM7u&eh$2wn{r-po8WZpsFs zd#xNW*>b@6|Jp=J29PtMlLs<5Uv~HQSpCv#gz~G`SQeL;b3hH4z-1bgtCfSDDLw}Z zRMxY#RTTX8zW4NP>T{t>us4ZK_oyIW;jl`_&&tAO!6Nn%MoH*MIsmxlMnS^8n{5-@(23o--wHxPOvUuy%6SA)4swGDs1q(GL|U zWVWVIs4C8I@Vnqo0YSZmCVM1vDfC?}Gej@}C!+2HRS!&lR+x94FpmiyE6Tbl#Yek6}kg2Z#_A_O&~BAgO74OmhiM_u#6w zZ}fCumG`Hfs@5Dofx+erow`OpV)tQoYu#Xz7C>tX+$|Io71jH`WMq5oxn83_<4AVt z6|9EN4}_a%c(7YF30!pxv3IyI0d7#dqke}DwakDm4EXYNzWL|>FbPl2|Jq9a_3CvJ zuuc5ypO*>$y*a!D-uJJs=GXt;3^py$1>ki(=Zrp{n-s!_g+qLQZz}_&{(GAmfXh;I zwM#DE;&X_#_wxUwj%v#@wDxUWs)10O19S;IH8X1T`X1ot|XIVW{Pd0j7%f zTZ82|5{66eHD){{;&)vIjyu$&Vc{||sRNlumE(>TxUlfgM@IsfUxiQH@VplzxDtDu zYi91--GvRCfg;A-(}KmD2{wyQkJ9XL1FCa>u4=rQFfcb(MW{qEr)^vasBR=P zgUVPKgjoJMF@{nX@3Vi%1VRb}&+aT6t=0@`&U_1h_wHjbBLw!N;Mg~AED>~BN5a7d zQaLWio#G124S32$PWT=jTLB?jM{(G&McdK1j+>>2LFE(G;d`60G#lh= z>sm@+TdrqC3mcYx>kicb7j&sec>Kgf*`N$8GnBi;<#808i>qu<40f3@6$FgO21&{r zv%J%GI01TNsi*P^??Q8S8VgfQ6?Y#oa&V;CT@0vZ0{>Vzt`atNkej*;a~sJ2@ZsY! zBdf{Kvx-->DWSa*tiM&;RH49#OB~4ciNaHPTb0^;WSK$%GdUx)S zcU+_oNYzs|`jYa1_KsOu5Rdr78#kV+UJ%IM=pE1bd0Fx2-lHH!CLBuXt8d@(!Rd~D z#U|F>7nhc8Hcqawu$40NH%KTmhQ{D#!XG>&X>D(3W)M=?h;YbxQ&}m+z<_)0vf{z1 zZ#_Ym;ud(wiiIJ{&YnK)ry9}S<|W|2r;B3MxG>b)c4Q3L@U$bIz3Tan{Y`(JU?K4+ z3i{5QCeL1`y(JcdWmbUj8PG(c>){W){AQ)< zEeXN#z=9iit`W_vwu5h!euamVO`I&(x+bOJ6!I0jGr8d`lnhqSE*_Z<$Qc$qWH0#= z&5CqxyZrjz`{}1jTo--Z)Wd=?eMM2+fJrkRiaREJtPdXiez#~L`AtbVPTf9&@z)Oq zu=0YRD#vP#xVv@1?nPVgo+z44y}}D#>|?GgC~h)2c43*ywHbPmk$hK_?>^LzFEuLgrhG!`|p~dKBacRn$Jmd#Aa9AK>_RqYQSjfDZpWDoU+~ zq%Y5Uzz~+H6jMj7K7no_CyUL5?6o4{u(b#OI$f47RTpXe(fxcyrNBF|$62m?BQL+| zB%j||H2Nytg-c~T;lF$9KR*#Msh$|hyPRh%!g#Ro=xjVKF?hkCY4)S%S4+vfzMiKS z7><;Im^A;`|AK@gHaHnIUUd}+@r_(Sd9&t00oQR=jWu|I&u|#8SVi$wI&mDzhyTQ| zCf1UrG>!A?pfJ5i9Z>j)?COX=Fa2i@rdW~?4%%2sS^o={`Tu?Do&8b_jEaTN*#k~; zg$QHg*b6$wZsv480VnJy^UvEW|FY=9`HJ&g(h=L~gZ}DBeq}R4;;9zw=w18MJ-wyWv-1!6H~IvV?td(0f-#zetV)DhY*1neSrcdO7mTq+y*C@c z8CC&5297oGOp8}RPT;|@uBr}G#t<`jXgH!GU~AvFRg_64Z96-<)Lm^~KV7;U-*baI z;px0B4eVkMat8uccn8A0L+fMCk0a04C$coo21=y#1(we(OQvFCqEbK9oi4fxEjr!? z-3g6TU)befiDO{0{<2Jl1#F#_64O|ixxkx08xa%#7_wW?Z?3MT9IgdSjcU()L16ef z&qUzpAI6RkD`QYS)6M&&U_UJ9RKz8e?xRgl&oB(l9u3W_x+{l+cxT2F|B{;mw_dFG z=$3?`^Us7R`lzT&e2MEs8NVsg-=v$sRHt@J4p}=Xvc^}kq33Z|e`W85D1l$*E-5K9 zLxx{3CxNYnkLD$?(lhJ6zp@h?O{MI=Ia8rB<*_k9+Eq_rXo~ZdNboRe*80#h*W4 zbkvwCb_+hRFzeiOq*QYkIIi*=vu9wyp&Su2K8Y#cnkFCI$>^C^{npd=gDxO7XaIp? zVdRJa$GrB~AoaG{@{(5N7klZ@_uA!+m1EGdy1RXTx&4%`V&0{IYSXS!s?P4-{DXF~ z?9RU2Buc-cb)=s4L)b7Coa0<9 z5)cp)+iXzHy^09ln1kPGj>VCfiH(oRJG;KP?9hPu>xJ^Az6PmS_~_4{*UPU$u;#uM z_o|m6g>PyADe1@O2M-optLUh_Wvf#dS-Q4Z1v4@;Gn=(3aei5Xo&KW0N1xy*IA5Y+ zyzcZgPQT;&6g&%jG=uYt86XG0j~Klm&`Ac`7~Mg?>jVl@T(%HGx887uq*p`sqZ;fyQcXSVNM$L+j)Ndor; zPdrPllwnE=FLYtYQ{=u4DQwtH)6B?iZ^)9z*#%!B@HbeUIvr^*z$-|UmL4%|Sa!*g z9|mh~UEb?>&Uu6}8Yzmg!F>Rup#G5)h|{8I=9aGE&ZlnB&x=C5Y( zNDmYQt=T%oy(K?2b7N4kuQfQ-X?8ut zrQrLLbP+ZjB97Kcb!Y$=1GBcCid6#Tw*;9l=A8Is0ilvDc3naa`$=csIX&3C=5Y)5 z)u^+lg9!T%$;l!=pUOlSkKFy_r0Eii3K=3u)c9c{&!BFd@@TfP)=3wi!(?Wc43RoG ztXs2Fp4*rm<{8+DHq^?$D2?^qsp=IdOPoy0u3AOJJ-iOP0Cuzy0Qixtezka zdCl^xtPpr%l-~6T84Q_IS2p%X?-pq3T5xU?Q$zY^MYW9ebNYs>6^psnJ)Tt!gQt*}5Ock%Wo2uRLHe z^lb8?5Xo%7BVH@$;vm1#4H3XlPbK}o%wo2X-oHONd86KDO_m{k5kp_qwd7xinH%HvYSyXS-hX+%I~1Wgs{5%R*8f{p6wSz^2$lZn_^s^1uM(l8zS$13 z93ztrnBj#hro7969uP}jMYW&r-r_Rq#~UYaWDIFcOx2t*dM{)P)jSl1I%+vY4ip`( zVE?SPw*{AiV(B|@OEvTzkSntvQSkGI=NW6~?Z(;7*6L~-I8{wjK}kYz&)1vvr|dxP z?zk&4op%HvbZ!T_7TFs`?Up?WzvqfUe|YGt3x2jH)I~xe@H_y0hUfzbN2yg5d%b0v zUYYGTb>+=`a?H6yjGNZW-8A{N;jC1lQilvAdP@(W0V;`nc4`XFkyxatU2|X0>#Kjn zU12TYSe*v&s;^oP^;afs^0T%lV^BB}j*o=|kLP{1tk(!RY+4Skq6Es~^6PfFt7hyc zAX~m`f(`+bZY!B{V|Jrs;%B*_L9?=iBu2w$5IZYb9T8T&jD)3L%VKU~SvdrRguMH; zIBRYvQVxAdd3EVuelWAK4S$q8cWZucSnAYnS{a(^+0vAAQvqESdO@d z1CV`<)K*VY>H(FIRcG4!T~84fX91o~w9iP28Ec&&LR9pyS#dJ))3+=Yl^r+|<+(qP zzh%|@qaD?>9^neKd7TB;DLgQuYp%^#yti7as-bDm#c1!F?em2;>z{pwq3RwkZ%j*- z_rgv`!r_3ydG%UcfScArg|W6(lf~Zt!lR-6IS-3$On?gj%-m+eFj^QK+>yP~A>LC& zACLKZAPzFmo<=r2y9KHNhx&6r9f`$|)V)2v+F~dxJ8viGt zPnmGg+;tzbb@A(_?j9225Q0`N&zv`UmRU~dxmeV*7PQmG4P|-E`D$imDY;J7nYx`r zGOUckityg!ekgxzd8nc*Zml?{U`P}}{cOm5)tOqLz$}Na`t;Qfh1Uer9g>C9J}04M z@8vMJ{_ewPq-G@0nZw7tLd_&>$ za__KM2nCguJ4}7YoGqJUs;DDmRm4$gVoALEclqqqmV1-2KoXD)%d#ji(yKtz^nB)$ zIz5OvKNquIx$Y)3e=!eZ5xCTSE;nSHkY7-*M-8sc_Z+3|jR+e<9WNWN9Ezid%>bgb zva-^$dHAE9ddMu!Fn_OC*jT^9ZY=Rm?juN76BNRXX;D!Xb8Zo!qkKQK707SVWaR1d zAq&L)$4Em-0HC8{N|W$4Gn0uvBh9keRK;@sOr^}?ut|8pW7@AE;d}aVg}AYRv3szg zDY(a4IRKIA8LpTUMDKEtJp9;WKNNV2fdya{d3qHCDE^sT!4k2{I?EvNXF^`quQ7c= z(5UtVTEb0!(9e`+5PVpj&p9o(Kbmy*^^J)m$4p=0iZ9#RCV_fgtN&u8_t%gU&Sejov8~Sb~ zgn51cTu9C%+KY$Ed9eMvoaaY*|A-_Jad|0 zBZ0@DR|giAD`?u3HCQyWo13mEEvoqfr2!q?& zFc%fY>)%YPSL*l;@G=nM7=Rk70=VZKngUvfZz8OyPO@k?JR@nHt+f?m{HepDfAdyo`-iHD3AB9KyLZL6 zUJD9Ngs8|dMaidw7J7$C1@gTtjY#FtvU*~9`!Rpl*u=rn0ma_7Y49-b%cItl)l-aB z6Qj#;m5m4LuBU$^QfqwF`k;s>wS%r=ie4UEw`M`f$x0x(!%xIc<}>GF83Q2&yi~4C z{|iuNgPOWJ=xN1#`t)sMsvO`pdEK7*(xD`gX&}#^G7_hzbj^O8E%K`YZ~!jGf38Fy z*e_cTRmVW^zU3iZ3-|`8t}Pn;&r98=z9A*VFwLRfs2FY6<&^;dIESM8TM#y*^SBC! zHA2}#_%L2=o2V#|nLrO%GiNjp%%AFf1x8XG8}6sL_EVz|BhyG8>R-yGrKi^fq=-vw z4ldnALQCW2dR5il9)i{>uBW&YmGd-)j+=rBXTv24`wJTBY2R4{%e@0jvrq88&UgMC zrv;!5gVNy33gPIOc;<+91g5E)KlLcw%^JaggOrn#V=Bpa@B%S1Iu;sz%F*Dzf@I-! zThg^ac9-;y*4O#CVf;J*7#K_8xal<_*5DNNT@qFFrX4XbJyEiM6rJIEwzKgXc`+dM zv9f!)uNeI1UHXni*X(;7DnlTs38=X}P2 zD#r^_H<6-HXP06Wbb#bwEpxi%CVD(<4Tq02F=vX!0+s5M`NTyEx0wbo=@H6p_HCUp zpxOaIvaJn}ewl`e3HB*gUphQJz@-VX8mRSZ6pB=ji~)n8g|$jZK5Lh5F1@1P87=l( zKx~NDbwzap0FP4aWVwi`GHqZPP2bVLqwf~I>EQ;wDJF&y5v-ch6D~v9z@cYxX~A+D z6efG6kB0O8@UICRinRXtv9=@I4#EK|>mzmIh&mNq{N#etQga+6(nT3EvoKo)1d5Mi z$PpSFEt}EqL*m#&BnP+$St_FUgIn%bOyzOJu@8jMG!R>XyI1Oz`XEp19Qpd2g6%LQ z=TL`MD1#4YVRsy{Kmy1J&=A0or1xH#_HfbA73+cGbmi3nsnJZPy2IY9ATcllFwdcG zCqY@TEF&_C*e|8<$(a32jPb{URyapAIEy8vyJcrxW@eprV(_Kz1ZL2UifB(YhK_Y1 z49cQWh_%=aN#eJMas~w`B(3v+4t|@|_$)1agd$zE#v=!qfOWUqbP|?F&_88}nk;iD z2yP1>c=s)k*Z1dfHEQWD$|B255Jp)yiaF0nl9qNJ-_WaGc~oXOB9j%Cm}yr-RQyD5 zf26n-AoRr(1ZA!RzEorNAY9+NFzWGi3Sm|;wU6v$UD)%EDe{5h{4pE9WFLgv`P71i zRDXJ+h(Y$>#pq^i;M><~kJWz=J#p!A0CgvKA}!tV?K_%hdMT|pv(|Ij1lJF^X$qJ* z#>HYe6P_wK45)3@p%NG(Ac-jIdgB5S%!hKjiQC2g7cy&r8@$RNxd#(~X`v#P7 zPrG~$S83s6Im=)CMlq*;$GaRL)$=B<=-0UCiJh#_RvEBM%gA&t?=drHeFBudY~Z-j z6E9lhXY5^a$OWlfXQZ=wI)8joHdaGCUTh>h|KPKEJ=KGj&eEz~h^v81Xk0q!3BdZok z=}_-R#Iu6UWPqt=P-h%ukD{{I?grrfc&?hGmFIT2GUYmQCk{l-6|@tvJj<6~iAP7b zCcz5MCwGenJ6$|_`izy{nysAD&gfx{8Mi*a1W}vs>{NnJX?o)G=4sg@=OumuaS%~% zr^r$Cd0c+|u9~4!hup)@n5^G9JEr9j4`3&Pr(-0ui+ykdQzdVK+DtXdNWL$>=FtlS zn7W}yPt1w)YGi=h#i{n2giBC1sHf^M^9DV~3yM^kiDr ztnaaxlHc!VUUf#;qB-q%Qq5Vy-RL5pxG#y^&kDpaAHZ7VaRt&)#YY(#o#CvYss9s+~mYSYwi3*#geEGs7DPC}7G{CE~^)<OzP;_ zN$7WF?a)1jt7l!mj;QxcDn?l&KaEfa3%d3c=fd{IJ}Vr9noB z`)kMbGOpzUSYA&r!N@ynH{u$fO>BOgk-u)AsIVp|tYtWAw`=X|P+Bo(Kg;y;5_o;W zwa~R{$tog}8&gyT0M(X~=QUuQw&k`O4SqCIB;|wJ^ebnFY8@+k#WBX*@lU;-P87WX z*TuD)%Z>+ls~AG(*I3SJYl{692kGMsQb2HYLwZMwB?<0*vq3!fy;0ou;g$A;~4YRsH5b~lkd;5 zA0o>vW~|0Q;6Yl}q+sbi?fl}r?No9CB4=$Au~sCZtqY|+E~&G(klVUk0MZ-%`tIXR=-Q({G1AxnX)A07mXmwCqf9;WlWEr0T{A_=w_V=&deQ5-@K9NZ=n^0O~eSs%;R86#ljj>$T938CH6?R%^UgWZe_0>%D26jViEzrm~GJ$l$ONdx7% zgAXwmuj)^}*BEYX~tm`}f0w8?IOjO=61PTS&VQhKA;@Ahwww9B!u2x@HaRKBaC zhE}3L8VZL92S?UE9XPDfMiVE#y7T*+fW|&GAA{(33szaJ8Sj^@si|m!DvP0uyg{EH zCaH3stQ5hMGir_jO&R91mO`@a_Eqie@zz8`nxG#zR%_)k^c^vz;*6p zne~vEDUhb7HRs03#U@MVyVtSo)-l5#6^wfh4QW1&&i67l4wVUG$Yr;!pcea+O8^K} zB#mP6TrRj6K0V`!O83upVjPZ;U*&?G3a zf`4>f{%)ku3Nd~zOY7P*kZ>31QJ>5kZ^T>eC((48BrO2UazIB5dHq_=UP~)M=5w|* z_D3WI#X-Rs#m?T*%TppDAs`7Wd!@OU7;+E&7wCnAzJVjPoS(*>9L_9QIhr)tQ`9$@ zAeC4Hi~zqEV?w=s{3@eZUEy^^wASIi{$JHy^KPBaYq=3@@P?fVLv2+IfA{^S1{b%_hcV+Dhp}Lifc|{1&sy0AUR{n@Oq`Q*%d?rJ;4PwNKU*uoqt0DL{f_J>Y<)!v`W0!NJ4P5xrr7Z%pt zwHM>Ac0*+a$IwN`|Lhk#mj=6N6pVhW)#jJz~?ub=hZPXLuAXCdW zh(PB*09Q-fN6DS!?F*V)l$Fgp=)yXnIJs{02PS>I@csqJ=T(hrFmcMJzGx1ugcgv1 zKtkE)MV+`8K*W*>pZS;PT=%jZNscLCHkUv~d?3CEmU&4ZL*<;_KGTYMxM}%M)GRpX z%zq%Zi42`j`<%r&n(i}dNNuZ+TbOyleFJmt1UcYt_WkGb=Z@?}AOVQ`?Ek>mF|Br! z(2Af*ArXOc!@q?yFLh_^CjBmwei#Tk)A4VQ0a};mU$tu%Eacy5v-O`9hh{WVL`PWK zwA}2|w!1bcV_d1%*uX;~{m;!m(<$Y;&uD^F-QPdM=~6ehTQk3{9Uw~rt#H2j$X?OG z9m?|XoO2m9#R2E}Hq4C%x0q*HTlanu@>I^$9FToe0CKzng_2yhw&t8$6(lPBBr0xe z(k78Y!7aC5edM8=;FqCdzbj#KcVHm+n)aYf2Nyjp4Gk5$D4#5Rh!>VFNjByjYnEX$ zRO#mdi4*)XY@GbrVcl<(V|_jg+EO9Y-hcTb9ezPOU}0{)y~!o0!CQ2`W9%j}<><7U zBwfa3O9k6oPy7-yRB9i;K_>t@TJGu^8y(96QrhDOK?dU$w{fEDMn}=n-upuHSMSex z2%Yy#g3@%}^NhkFp=eHtl9h9vQAB^ao0N4-RcI%Oj@T`-%}QhSpgd_zu(Fy<*RE~M z*wMT4mX5yUM#I=@*T(ZrUibed5aV0B6S6VyrcBPI_x2@Uvz%YBo=z3NVb25o9jwmT zG#h2A_74F=CytNG?Vq6loLjo{rEz9%>2=dX&!Q*hFc{?=7TYMf?8XtJ_JO~M`NEn} z8{rs(kBgKXo{MAs3%NZxMb`+7Tl_5(N>{|&+eP%s@3F?w>K1`${|syip}B&xf8iW~ zE&Sdfwsv6gkOxhm@r=xpSw$BYtbpA;-$ZLF8g}M6BJ1mR@Iz6sGVgE`O!oV<`+f|FIO2s?giS@Ni-^owPH=t+=R z?)Y*4XZ0kWrHag}yI$Irup&Fihjup@Xi|3Zr8xPELP*ls5hbQ?_;6GJ${Q5ZPnvUK z4hX0dXQ~D6+_RXw2Yx2SQrUw~FlF(sVt{|m(A=*UR$(}jqtatFv-?#~M!%pX)w1YJ}C zVEg})Ys3FXu+1&T)V&ELZGT3PUy?TVY>R$fpB`qCmynv^zaxe962VU$KI#>O(rEv+ zW@)qpI$rq~i67ZzqUXSn`~~d48V%U^`|npuB)Q4HC_E6$Bi3td2i<>7FgGC0@fTQE zqcD~E{a=EvPfGgpZ?6;NKHh{*|FMGq^=;n+-!|h$;ok2D2tNn_6$|G=|INzw7CsXIh|MVjWn+83zhc{oEsDwj%=!4x6`$ z1nfcjL0ZXs!Byo(+=u{J7EUn*;T}Dpx8JbV89QyW`AyYry|yJn?E(=@Xj>j5klBId zd_cDLc~4D9;Es|C6G+rGU1`{UyaZO7D;e3W_i!kjr}-s3d}i@pi4`aUStfd*ekjnb zr^Ekncj;g?IL~+QGrdqzZUFw+3LdR;NOIfmxdk*`7jWy7IwkAc9Eh9Xo*k@dvwW10qfwzE?{cx@q=wsOtCii_*mReX>*`2Y-O0g?l@T5pViGq@ zFmscMWy-v<@7g}dyDyo7@s{qZaT$bTzP&j|FLAVUJ_0GQY@&=jsfpt`)S9fzUfLb! zqtrKq1jOpg8R~JR47XC~C!~;BV#0H^cB2uXt0N40Idn9{2H07JRxH zfuH!;SAsDWz2LS{zzHd2YchN z%|D^dHqt?BXOBRJMbGy>&*hOlp`{}E=H+tTlM$rog=0%`wv&R5la65)K76r+YSGBp zB-|=eC0;1!**lRJpjtBEtqEhZ&Wy)w{D=|RI3%yFI|y?}xMG~@c6;2%Qs>{@_HVwS zYbAD)gzw9bI4P)LLrJKiq^p~%9}qSMDIIFM!c%M@jTBT|QYL4XFai>}K~N+cJ`GtXPkFWvtOXIS|vwZq2Xa zHrpg^7ki88?=AS?YfVtHU|B+p9ja$Agt~sHY*rq`#oTeji_3Fxv?`WT@_YYwADq_R zO@{PLjQC7B-IK?A3N-I10}0r0$#N-d?V(I(INl%(e_uCTF#J zBJ*}0(@bJe3Ex#B91}Mq!ksQ>^KGmZE$dbd`%114M=)%E+Uz?85fOBz1R3Sdw@{eR z7A((4P*E1?*u^fA)A2t7syS_-A%UksnZu$R(n0M21A#TuZO5yM!AvU`tTn1oVbwR% zCwWz}6ij+?yW>uF1li#Ciavr8ch~?A4M~EI(y(4v5A7h1S0$j^+(p7Iz1+hAn|4&5Y5LPW%%WGU=Aw1U9H!@oF1fs^lC+P z17(j7fzW{ioEogVvvUh5pn zu~ZDskMPP+8fyun@l7JD9+esY%0UID@?Kj$IUOnTV)MaUR)sLp_fPPKrpfjA^+BbP zvv4N8Op?1V-pwD)t9!Ub`E-@Kk)BJi_h#s5o-0CdOVV9`>KnF5uV)4A8ix0cmx0b?sDav7sOgHmppc7+i^~D9+h!S)eX`A0|DB(OjSXwi3=Ns0 zs}?L=T%P>WbyWkslB4$Q=N=0o<_04lA1--JZ_4=56%QS|(OegxOw9qh9Il%g-hYK2`W87u;4LujBViVfAFPQ-Zyc;(vytwFZ zP~TPpdzLt{$&bZvXr;DfRVPcGTow9zPv0)|rWEFak(HQ)tUpY6 zN^fD$=el$d*#OWU3fh0U(}R>W1z)MTxmEJty9dZH3ao)S4HtnQ3`#{Fj}LnzfENXp zBaH?(E*4O)?S*2?3ToTwrb&=<4xfA&uXQcF%^}_>10YV6MobE$*Ud7^t|vp~^JND| z+k)v1eiv6X451W;SSJje`oQ5sVOibTL)P?|AgFAi%Mj9Y-`%2=!oi*d{Db8C6uELyO`M6d5#Ml zcNzQOR%WRk?#f3cvdI+4Xk4=*P4`Y~gV|c|uqDht>6i;)O=Whym>-4}W#_LiKH_}=Ee!eUy z)OeM_{>g-|rlEt=_DPvsRhG6Ps*-cMPefv*J7tUEFnQ5v5>BYibVC%2xIVRB8L|?# zp4QDt7v z8la5lh?C=@RbDYQEz)3*!_-`Abr~)8*_x#eVZoe#6y@V}z`w?yp_e^{YnFn=PzD2! z6kf<_MEI4$N}^6Hy0s|WZi*0&%R!+JE>qaFFvzdWM_oN5VCQ|uFdyn?QO4&R%y=oF z2eQ`vd~2(iR|bA;EtslK_*a_p25ax(odPr7R2H;p(RA<@EpigZ3?YGSujr^FVqb)^(q>Is;1M+#@ zK(0a`ls=GIJyO=To-Mw2E!~1YzkZ{a>~`HGllZ~XYT`YsAXb%Y#Vr&JfIg8fSB*2l zHkE={z-5R31TVD1HWf=7Y;Q5$ zmNyvP+LYFWErU*u=M7?OIXS|TXf5pc9A%jiC&jyU?!f&+y*@@r~pZt)UHQ9kK5tZ7&Jd~mgn0$S1% zTC~OSaYdWa@+)fw!I8Dg175Z3i*+bdre^_I{ zM-_%)3c^OI8=GJb_3Q8X%fFueQMADi?%e;kI!fAgS}>3M^`~}`?2n|4{Vd6!dM8w; zU;UZCoqzdJ9%_s;qa{CC&+~`UatWdoVGsxsX#M)Xt`abS`Hc#;!5p=n983?vnWe5U zc${9QIwdt82_vC*V6GzS4O=U&|;c^yqZIFKx+Xj@aNM z0cCye5x+PR=!{j_Xc7Ngi}Y3KxylngII4Wqo*au#kN)DtP^cu*6|{&Jf|fdy=WkgE zc9X2Sbf_kob&kaoPNkfFa^R0B81>Bzgyx9ckelm!P`qzQ~W9E{Ek+Xf{bkUKBS$KC@xANg3CI-rQe?-4K=)RvT@n zq&zJ*eJ0AsUImsALx`|prkK7p$35Lk3{D%{)P?qY(TJ?a&{9YzYce~s4;g>t7A9VM zOX2i$-VVXttfRTyw#+CAe^IaxJ5{I|?Ut{9xk$lz;|q}6{iH^PRteL3(V)>PK{Dx9 z2_y+~fP;zM>E|WZKW1QT!9S}#h5-lu65bR*w6f!u+JVi#AF;3heLKGa|GZ)3EEH&Q zmrUAGH?qMoms^O0ooL61#xSIOhh4w{X?Tn;CfmE18mhaz*QA^0C6&58IIrRCTm~P+?RD7|Lu%}g*{OtB zIs}wuGSr8MMmS@46mpcGjn?6+fNr8c$>I_h@vhOLw9`XUoy7!ce` zpPYSbI9dS75tWu{|-8+zI+e@ZzfgYW6`!erdPp`z!E9egr zBBz(_97M+Za%>8O)QYX|=f!aI+xy91lG?Lioh|2ti*Ht|O2E#!b5?iMiuG>iWpPKv zg3hL#{jHpXC%22u$!gw;JMT zW!2)&<4(b_F^F+79EYwW|GaRuc0AHy&+eO7QJIetL~i}Vl|sR)hN5qZ-6>Y#xQgQm z14GwUDDB;U4|w-x=2=!iZwEPgu|&~ujOekPLdtwT}n zR))PnABf?8eS`YdD2JqxI0lrR*rolTRUr_eUsCyRV{jcEa<|8Fuyuxplu%$?*W~KR zt^;>Gg}L1-QCk0h_ABiA2%by6fAnIN&@wONmKA3bVPkXr*^O+qQjn`d6*9gAtWPb$ zPiGHs<=0RCae=A9|WWME@=S?BH1R- z&wc!Dmgj=NH$h&-3{usf?F9dQ6odx{ua`HC^pHA3I|52TcBC|7_jeesW0k3`pLNkx3)~1qpZ1FU`usY2DXUg$W5{FCvsCQdt_ik z$GKahHIiGSxpnU%87GY@dxDa}(=OH1L{jmVdLXBDu5w)a`?iM)zCtK3(Mu!F;-L(P zPr6BxrzVpH+G$?_S%?29yS%?JC)XFHY@F2*2aBvvtFA-9vjhsJZHD6=n#i_nfK|=2WvB)WxtKW1S!jpTF{RqgQ58$e|L<%LZ>tqzTh>j`CQd zZ)^?^>Pxq2lg_=XB^doZ_TBgJ$s zK*qt-MH+8%FHCuUzDi!_JuqoZt!6# zb45*SMlMU;nA=bTH zD-;Enp}JJ&c%6JijJKfkldXG9s_ZsjMDrA&C7-BrE_zkDvGx-y?sR5MIAP!OEm3kg z^KyNhZj4aBP4Y5xtX#>)5A-6%ZqRs+CP$C)N8ry)&F0PzE97e6x@s4!ZYF4HR5gqM zFGXKNh$X7yVBTo^UM#F+D+C-IA6msRx6T~&@4;W$E>hqBsXR$jgFc(%cae~iHx&)Nz zg4FS@cH>o*WKiR|f6ZT7{(_S~u@qmw5nG=C+(QGzchv-~@Gtl6AK<vdT;Hmdn>J@FR2l%9S{c5(R+Ck> zI;ot@xcuk11j+e&%drK%ND5K5iiVCUj6POD5Z%LUx9ew9J$rjY1@rSs_6FRk8w>TL zvtF)h9V0Kqgp;gbWvrSrzwW?8e7g8Oa~3Wd&YvIQ#)5!-{^64i$Lv#uE918$o6-}p zCF(tex*R+E(F(G5s}YPxOAoZ0-SSU4P1Ovb9IAC>aWJ2b`LO+&spCT19Fuel9KXD> zDIMx$qcwzY|M81zM;%;OJeLsu35!l|fhmRmW@WcldBEX6AX=1igDitDV?;6 zYm=>(Gxibqf27gaI7@{6r-i!3oUYi{Z7lLYf5q1?lgpTW?D^#pX#{h&Vaa{dk5bC7 zR!`i<#u2#JjRMYvE-AaKjUbQo=wLTcvTtA`{C!?y7589%@J2S$s>fb!W2f45KtA|; z31MrbuR|u~lsEcgndJVoAtGSw{#yOo_2Zou&x38cw&l)k`reN_TdonyUyeO)dcrSC ztUJVP7^DrVB_wel$T1vOuzVX|W15a1AuOG2t~R@Ua>O(+W2^DUoHiQ|Feb;@q2ZC> z)=2IqkSaWB#JtGNg2hXn(Gsxf!hKO1OWUc>?{?Xpj*Ud`XJb<%DW;6*O_N1>cd%Ke z4srTTJEu9A#%so)Ib{*|fTF0hup%SQR zG3`d9lC^NPO*7#_>!epy2U|7?9eNduZjMb6m%EyEIrTQXSP7TW9awt9Pj+i>m9&2? zOOr#~s|{B-v(C1&0Am$FCZ@Hq^ZFYw9XIvcX*xNcBlekV9^6@3+;(ptVC$IQWxq_Z z{C$?yZYS=^POoQ|d!Ht1IU#ssWRbZkUX#2wnTm_ejG{z$KG)n$2;1EYzQLv=?sCGs}@ zlsh?TgzRG)$W+Gcqs-(Y-^VB^47|5MD-d^O!@9ZhFW$Kw??)BNajxD{uGRC|7q^)o z%W3nmG+4MVeDr|RMd8(f{NYAi1Se72p^IZx1h-58olbqS;rCp(zhW`zA>C3Ioa*?P z4F3-_{Prrq=0s_nqjqq8M^>n;qne+5%Tw2US-3crtIRgMz_hONM76*}xzK%xW6Y0P zA2urP<6EW`(xC}99QpQwKjf&K{L4d{TwR)Y)2c0Xg<}`-D6Pt2qViZr&XzkC%mR-U zKGP2k{Yk?FOcnOXZI=bxG2>?YqhHy5NZx9tI3DSQ zGeWBP8u~iu>)@?AULsu)eQbDN&5vWxdz+6~YsF2xk6RHxUe5w>uruK6#I9vnc#pTi zKt_f)c86zhKYluE*{M0h-A-o&>vFV#Pun_qHTMi5xU4I?lFQr_ecS)%`f7;GEf}$y zVFxN=YIx%x&^IqOk(bQw!3__ecudFynOT3EYoR(nVmokz6RuW49Nvt@G3Yfn;C7#& zk895PtPgZ+;*rX*ENB$P{qOI?4w{M8{>nYk=VZ7p5}I`nfAje0Ri#_N)9d^+kx%_o z=!%k`J)heJYQ5MDvt>$Qd5T;C*rLX5q878-R~b+eG1VnRbM_EV%Ge%nqWLF+{scS*Sz$=lxsYj1DOlkcK&43jIHYs6J*9W&LHg%sXh z{9MK19wy39O%z(p?MfR!3ROpEYsj&faeim3LfgZH*!+JEh%H2NT`T;l4vx;%RwWjF zX;^j%AItnG0_~_|&mXhE^4ipP=rxsDcibA~a&6m5dJ?awE-B+aLyG5em_fZypsig& z<=)8g%HBO?%~_A>+~L^`gfpy+Y%J+G*iE$>F3(<(eD?Z#c+P&6`KWm;CGG8Tl9bgK z(n0c3I=sf1zxqqVC(U%ZX<}~|8#FySCMAz74(#2foDN90Vy=~iCH)Qq3%KqHr+q8K zp(JT)CzN+ZHoGslkB+vj?noNvTQMKkhl|XAFTxQozie6)yM^5S^JKXAVM>Of z9ld$~EOhLC-g&4_qQ$)D=$#7M8|lV=C5lFuSw8AX`>qSwsLArP*q6*l(ljuvFP>fQ z)io!XmLMXqmFCRIc!S!g@GI$FCwlUdi-YIiWVy0}d?rIHaKJ8+VfzY)_FS-TOpzXU zA1_-~9p&@&)|qDtQBfThT!tmOdt=V{2MIQ8yW&6P)_i|N8le61fm;WM(KcoWkujv- zND3difKQ67HW>F!-JAZ28YwYIx41cAanZT|9VlMwlMPOE49$01hO)>L2iaq6d070r zw6q7@AT-HPA?K0nWJq{KBLZ;9g+Bnm*}ums=|u zd{Xr8YCh%9apTTv@7A?gZ4TsKzYLs8uv2ljn?m`Q+nW5>m_`qrQWGLJbN4KAF7pp^ zu)sR_EQ{7pVFlH-2!sNnMkT0~^)T%&|25v$tA3pM4%_h}!sD`=fyfRKzpHc}y1~O| z)9!R0P-;0NH5&IZt_s;2iMV53pVfI-aMW?kP?P#xu$HFwubWn{GGTg5N-^-Vwqj>@ z2Myov42T*^6h&W1Ql_s6bvro0=tWdUD<0rHFj(dV zyb*MslkZjZp2V%#y;zu2BKG05Tr5)libqV27^n9HranS$x~Ic0-*>!)ALWhT9{Juz z&oAY(BDB0fnk`Xp5$@cv!LO+dNvS@erKxRW%KiDNPwO7;eCt9J-(sG4Vo+*=##%kB z{WWaW@=LNygy>8>u^yG_QS2#{aq-xmW4wPf;~cHn*8ta(N0;;AD?j`BSnZ#D7Pp(smft`D>BL~)^#YA(csqePo)OM zbqnFaIN(s*VBQn?Y&D6x?Q~cP?n8?Fk^P&|ZOiYBg`>yUhTT2Ice_r68c)6rCN^y6 zAZ9*nj^X<=VL|6DGZz}_<#o6zBVpRF}o?t?)`)pbE z9B=wE3V770NZ3=Jv9p`o^1AmbPPMtDw&Lm1yUk2o67gpcGQV!@Yc($My&wL1(fF`h z7WY#!#~M>bj2j~MT&cD)Qp0Zud3>50Gdp5JxK;PS?%xAMJJ)bF-Qo3>%U?vpr;UZ` zjoVUuOjPhX5aP`+=j2Y-4l+2~STRf1F*d+8HE{w`Va>W#9lguIS=)#yR>hY$0~bC^ z!1?(OJg!K}$hPMDXaT)UIu+G~*Gd$4G*)4{&?4gB=~B2|>zmy)kCCOu#NSw+Rj>&@ zhk>7HZo$0J8A}49x<)tYmPz3CcaHMta#SNDIX$#~~jQT}Lil<G z^V0?07!+8AIqVvY_FN~g<_jB{G&-huG%dAKpwI`cp>qk(>b@Tful{g#4@Qh}@^4dT z-y-t<%xJo#@tkSCu=xbRx36!rJUxSk*Abrz>r<1*a`tbQZLZfYyA&HYmIpwzQqul| zh1*fc%!e(ArY~#um}4h}`YN-g_Hh3BPu-e{)nc)9ZblUk>yG~E){M?I(Wt3|=8R2J zlN%1M**t@7y3;i_^=vR4n7PU9P&hs!lNjokSoYEbrX=6G;!Pm$8OSFvaRpXrJertA z%)FJPuA_p)TlIAnk4Y3|-$s7Es@R+qv;S?E z;gUr2|6sdc)UMfj*-^l=fGExT->3)#0vu^?$VFee0U6GpP)(ZflV_KuP}mycFB*&Q zdT&BSe?l^P5g+CMT>p$&8GAui=LKU@$SpCe(^6ea!_hNib@vSmh7%{UZNLDzS!Org zY`x4O&k&E~=jQL$l29am&f9ZGGb++s$QlI9I72(hK+wF;yv%Sg#IWjjWXwFy!J;~w zp~y~=t!z^$hs;4D?=}ieB&A!``tEW#kb8u#~I4Ix?Qk*Ye1G=LuidOvSC+gR7;J_!LV%zDrzBBZgT z$YHHRHFuIXw1W_YYXMQ&XJ3Btw@^kQa^cI2nztdU*R87@I)TS<$U`gy@e5y{!G_em(B+&%Y#{Pq_vzN8oiQKqfg!0;9WKLf}DY*)%_Fl|UKEIQZI@J2u)R>W>Q>IVI-2oOx|)Yv+zF z1Y%s};s67aVi3AyLXkiS)qIqkAsNR&DL?AOYl(KhQNnw&grcKQ4i+d#95V=<(;h43 z;7C$xNo)hUgx)9bIYwh*T9rC8(?a~4Sc#*(SK*T=Wc6nSfoZp;wo2($CD7CMG$rqI zSf1(?P!4<;tMCu8LYBQkVcstg#~sq?sNA{E9oIIQ`|iNWNgn@1@6hx(^PAwNEQG~o3xUrU33kkQQ&>iE*=8OK*u5=%8z?z*=aZJ1zrgnKLOSGU};bv$d(JH9{B(Ft^7y8EkWjd9|z z-@LEPt7?8>Yzzb1MZih*grx3Ymck43KjDRn_#_e zZjoahKw@@(${!o#f?bJ@j+CoZM!8p4F+t~f;_I=+YvX#njQ@;`5KE>1=U3{+zrukQ z9J2N~IADySB{`l(ra{a>*fQ;ijUA!pWF4XAqzn9;9(Jl3R*{eUVzV1Ro`?1;&4kdo znP1{?qb{gg`L3k#rfeL*TMz%!>JsOmF@rBvqd)9y7@#HYV)r7YL3`(!-n&B|mJk)Al4~di-5^P;w=N#EF4Kd@8l4pBfNExm<4UYOn8xb=)4N*ynv1F4M9`WXODj*8+ z`uWeeZ{Krhzv;_VatcD|zG7pulQsjF-su#Rw!0Oib2@wMel!leW_}c!uNpKGxQbgI zD*{U9$K)9euZ@Xvcvpp+-AV4~+rWxwC6(qO9M!p7h1(!ERTVn}|7$kh-)+{^XmsVv zOieu7=B%#M>S$k2BW-?bt$)3EKh{QDrlyuS&39#%U!VXb^XiBuMPwxVat_thu3$#0xh;Gn{Z=mjc zf`7jTL?h@EfJ)bTv(n;T4@b z*I*k=-0C%P^h{}-t_%TvWhxuaV73ZgKa4z@w^F`S#7x-OV!ZQIPLh{$t`<)>w8crn zO0;t=P76_7ASFdffOc1FHVmb;1qJn+pS^eB-;86WRm~nOC^=_+5RySrKxny5SE!bD zO4||H=^ZP=;is-P({;MyE8xe>MT92xndOrQ?d+afhy{r*4HfNb4tFa51#ao@0*Wj! z&QN1p5ofTiw0C>oUvjDe3|&uvWkib~`*W)v_oY46Zcc6MgwlR+f3!C0dI>1dl6V{m z+3@4psKlwrUJu#&&AK}On*%ik=6)J9QXA@Y6`_>;mh&D)*s8BL*QQIL+%Gc$=ku< z*P+Tkr}hyACy`ofb|r_}{}=)bklU4}+m+oS%l2zItafUG+4)}%D&TLcf=yQ@G5`Ep z4-5Cl2om@n6h>GM>sQhiAunL(DS_?r8LXVe$e(_`@W;~sm)2b;JazQS`s3=w5;xvM zFhL|3cY^t{4gtXHU+;F{2`Sqx?ZV2VE?tHqMflOngWa-F+?ebtANTKvj1&zt39AeeX-dSlKE+yjrf}a&;Zy?z4#%Ci`R1vT zZ~n)+MJEYqbZnvf$4ul(pDN5&I9UR-mHZ6D`mdCEtsKO#n#~+_sCAmo>a%@>%ABvtlC(K!6+XS|nyeK#g@=tSZ-CK@~ zMDV0N`$vq7n{DIsYnr_A)q24xpg1S*vA(MWoU7bcY2bhWWyS?}KV12hiFQ6pkc)x4 zP3Ji4M;~*+(bqx?Tui6IobD*9M9pvVVIX``Dy4l&3ai5D-f4Np%ExvtKyZiW|OzS+o;Jy+$^8J3R$I9|8beDI8b?=a>sjuu68R`< z2r*t1G2MTtI$eTR42VNjHd7#G5f12a3we#!tusFRsfy^I!F}_*>xB+`2&Kamms)!d z*+K_Rd_V$C&ymtp({bhEz@lyZE7hpm#@=7Ar+7_D;-;cCD{Tkt$*+~6!|UcYI2pwr zqNc!H+-nU!mwnmU*#E~J2O-C(AO~@s;~8<$voV{A zKUVLdyKb;4MoAMyBE3wq4aN~Pr<)dgON~1}x`NkLIOrw4#db{jQD;-lbKiF-O*4`X( zIPZsh*{${Pg43AH`ape`h=uQHmu&ljDSoJ?bw+1^b)2jG$M%5%Lnz8P4XugN@xvL3 z9E3%|#a>cLDBq9dqBbAydggC6Hgo7#C=jdq$@RW46#62`SUeokrW$M2CBIUEP1xV> zgZhiR54>Wh@QX&p`qyGmm2%ApJ1z&o8_|NRalrz(k^}>R3XN3r;LqR1?f@*f%KGXAU+n$AXsH$Syw^+&cbZ^R(m>&gGv|j-P`FXkinq8`PRZ`(-xvPcDI$7(8Zg$WJrU zrWro)ZZp^}-j!LW(g!WkxLv&!L#f?dycd(8Vsla-VLdTQ**>$DiCUL5loW$gJ|ppY zpRakMG5LR+3)%^;CxbPF!S==wg~TQmJ@Ag*jO9uyB7NRl?sB5U=(El=*;Cj$? zfaS4TDPM%&wVDr0UuA~Vi`kA8+Dwo0y0r`{Bz+ZW3YFjJWsO#O-OdW;m3TP5;hGiT zyB2+3s|fQXp0T)zIM`-K<%(sF#=*~4Qum|bh_1DbOe?4R%BV^F)fBJeLB!AR41`5b zi;Oa7Q2|O(I|szN9j1sh`N*;TlDbohiKH*~p1ItihySGMigj?V$#yf)W)@2$YMW+DLN#=3_GmpU zOvOhhzp4Tr)>5lZci}+#V#ENeVVw0sWvtIhj+6U7eBrRe{O(Tw8Kmv%BB$?Pz>nL6X_o#5 zhmAX7?_vEjvFpd`Y?Jp@NRbs`8uFUx*t;pu)_QS*h!A@hs(FX@Q&Lysscoe1oFmWw<~=rV`VS~2G11sl`aoJ<2c z%_kK_9=T%k(t>`-X!Q3A9J3Uy`-f#09NqQ8$@~XTDAjS_5_(3g5_=|WXDG;?pGtE3 z#zJ2=+{;fUZMflF3Wo9#-h^Calb7y*niMIV8W5JuiwbE7SS#I7FW2MdsRF~0153uG zHFzbqTqJo#^x1n4@$Ulu_!D@gueMFys>@VJN+?+K$u7dE3r-!d_wQ-7GJf8Byd@6o zXz78ZU3`xnE|Ev4(1KRs^G${9D230NC5bMa1DceawV?xX$-t`fGuf* z#0*uU=qNchySN6I_0+NtjjxBc@F+&AHaSqnnduogCLO-$ex&8kmcrMv@UF4%;y#FZ zf2BlWkriIpXp3!Jw3%yrJ&q`(I2nw4qr^i;mi}H-vIbs*yg2w9btqM;sPR+&f+k-i zS7;AM5(;T_%hPVq9m_F#?5PivvRdY#cjueiOGUIie#AQ9gGu^lzx!eq=2O_AUVc7U^{&TEd-}!y z!`ORtI0O?3?0g>LN1f(t$0R;i+0@6z$KKP)=+)Arj=j#s{ zvQGJSXvTH45+amFP5vyAcA(jnk1%U9;8_Z@tC8yenlgEUrS!sSBf{hZP&OfE=R69; z2HGp2Fz&{pvq2%0zEh5~Safw3JScH^(sk_ybRYj?Ot|L7=C%AYF5PF+O5u%eqDYG9yQU#J3KB%y~|uzJDfeLb)wzsT#{o? z)vhi>uax=4jPnH27`1$}ZJnT$0qr5`{YSMu6a%8!L|;Uog0mkZ>R{`X+x!Gc%G@e* z8QBtREz;XOhrc8oKXQwaGNWW?@|^ATYJbW%#E7bh;LwPv;TeprmmsT7Nr0gV&yw)O zn%2IpP~FL~&I_lzVEq-d#a0gLY;`uKV_W9Li#!WKi>L0YSO)NX!q9_XUN=>jZyreW z`vqh?+8B|l)AvniUYH}#5yM}Yc-qvJQO;DT&c8RZXA00K2BNovl8QHl_*NrK2-p;=iOLzU@X5uX#=S7APjqXi>-s&R!wvcv zrNz+?v1#f&5>zVyi)%oYUCSW7OO>VB7Jd`eX}0&piC^L8Ab;k8gQ-Gr?ayW+k6QhY z7wQ;fN}9Sj>!M3Z|E%BniGBAxt$|=35sBuAIQ=C#zniQkP$d#zE6Zu*gY(-Y1!;~2 z3uY;jX=5j9eP+sWz^D}qbf?YeX#YCnAyur}zlNC&vN|y)M32vbvq8*!t=Hq`tD>~Ud&lc^gc-8FM(0ggo@+>d32k(RHzT|Gi&uMtwBEP9(uJobwkor`XDB7t%n8>Bhlcx?( zFx%?zG5ofV(2|&ll0Q=p&3o9W@(B}dAn)Ts=BjCE2R21JPY{y+#yWPPfq3}Q-;Cq# zb3q4Ukh?90WhOT>I!elYkCH8B&joLQ!HN+7fM?qSKw#hF#jy-#tBQw$d5yOis&{=I7Z=M@~fk-8QtOVYQXi3bXkH0~aSJm}TY=(kXIrs9r9Ji|W@r5X8 z@e_=h@=J;IfWbokE28S=ZwHAsPmRF-U#?a8oO@eGI7C<^UWATu7z!s9hKpq=T92l% z(E!_uZ+Z(wU77sr-`^ zWT51$CTVxs7A2k;JbE}>I9FG~+c9sH=iH<>9C|KUW0Acwd^+D3+l+PB*4C+gGBm0ValdZvH%gs6QA4}C6lVIfZqYE-D(z4XeF2Ys+C`D|14R7$qox?kDT_#AgR zYt>-t&Q?N!jWBR`HodHH`Xc~>`TOd2G5v^+?TjjY{9(i-kl z4b=2d+#c~5w6O+o6!Jdhz^(e5ttM@Vw?_?CCj*6|aCp`rlmZ?`yU#!+vgMIKPQxEX z4C7%9;ntV=mA1`5GgG&NN|jO)kqhN?S&X$~tAdueE9^jL{CmY^W+WMB_|ZB(&91WK zdT8xa%NTe0O*iU788Ztb&*HNQGuIlqvfnTL>gES#f<{}K!4~RdoJRDbu)FNZ$C7d! zw;xtL(`(?^&1&~|aiUTPOo19$_JfEq!4izi?!bry9V*vj)^hCHkuc;;_xpxt@&3a{ zmp{-BwH&}mNtD#&p4$v*MYk4Gttf0FSgOTt-&90jDYhJqS_!20Wt^FC*|jRfL@%$t z?*rBo;f)3_V5Qp?=7VFanY&|_-5-osHzBp(i{n3mdF+RAbZ2}S!Ng*Z?6pCSkoL;g z?dL+X!qX~2YUMuSAu{H&3oU60LBW4iRUQ=5)^07HuLYf?OU-|J+VLGT!y#OhydsDH zF!VTt2L9+i@lW&qqW}c?-(>Xvf-@j`{1c}6KQ)5?za)a&W)arnzgqHl!1U;UGqQ&+ z{7<`gEExQytl^j6q|XTH|2NEd`yvz^tnj$ zMBzW_CaXZ>|zC~zCb8dWH&tsj8~nJOfSrWo3<=I& zZ|Q}~%bd>Hz3ErDW&D)tsAG5DglMqV8n{vAtYV&&OWSpUN(L*mS*k~{z4$$9~e zuc?s-f*bxmT_(nnqhQ!<7-s|J-&o2&!eMuU<5u6X(d6g~Mp%C(ZQ;IgJg3mz9rPR9 zxwRME?(4@;1Gu?dj))3YBud;&z=B5#q)Xqtj#2{laJ&AnDVnTf{U3BVsmFb0!=8>R*M^&&1gNj+c2r_!MGk@RTEJGd7bg5 zuTujJgYJpdCSR_9!0jPbUU{!iCwr=}pDw_sAGnc~14>B1{ty}&j6Zu;1{{vi<%gXe zwJxZ+l{zC(&B%S>Ohy8zioPFT48lb+UfxM-Ja6envE-W_e&})0 zfudF7i=JiKS1^K>*naJ$D@Z9V{dq?Ni5F(UR@TODpAle`xaz`I3C(I&RGCwv^#~gHNQLD?w~;{>XwMF<*YSTMCjwPhw| zC%<@iBEGRqy=VAmSUqIO?TenCnb96pn@sjII;v5Lf3Ay5Qa0k=SM7u32)Kw!H|36{ zOiiAG?2Tq&t<22ak>2Cavq6FDD2V=aH2zS^|8cZBfI;H({Obw5*LL(mFB$F{qbJ!F z-H12p$7IUP2X?$&-1^Hw0RO)?+~bYtI2xP{j#M>V0@#nfWrD>K6IIRyoG&cC`h=$Q z#CTx$YzwN6fN=36uJQK>Xjlszl!0OCkIlSdwZ2|@>c6WFmTT!>iRKHOF|h)x4r;}LcE+_F3_w>NN7)H@JDb~= z)&0%=s%THz&3r1V}y)!0#ELCKe6%zuZVXIIQU@uD9Jb8-hu4)VGIAGG>eZx*4m1b5k{YFn!o^y?C{A=MyQ zhPWKR$sNrAE0C@q{;2_DfVke$VgyO2roW~vZC95hw?|p<0?8fU0vG5c8hxNg6z`#-oHm0dkm@px2S(W=R25Wh7i$Q zzfAs~r%l>((WgXD5V^X76Znzsc&0T{av6E~T&h6bVY#X7l@yQZxest2$>d|*VfiuW z90Tdc6Ribo3hiCFj}hPhGd18pb-5Z~wBra*bH+cj`g@eA+x708uW56Da_a|Pe{Rq3 z>lMajCDz?b(at#=0IBB5WFbu2BydcP?@PIP2(Ba?&rSWQ|K6S$yBr&I#4D#>LaE+u zc7yBgy}W_Zm*^lupFs8Z3~xhU`Hz=B3c}iL=Nml(y-!7)MjI6RWgdKDODoc1;^w1u zpn(A9mV#&F`|W!d28`7n_gw7JsldXYTrGRWni?|?URe@rDnkZzT|PA``b_xIu)MA?{Kf!^(jeo4Oc~~!fh86y@c}1& zjXkY%H@7wk9Er}Mk8F%(Rq_$osj%h{VEHls4VmgGjg9pj^- zqmoEHrGA=4n`o}Ti+jz>)06LXau+?lrf=`MHAn-guWg=c#!`%&w8&olC3{z=012AA z(c_9o&tUv(xd|_087XWn$-J57u0{reV|kdZ?a3rLQ4=9ZEhir-BqP9&HAk37foUFO(2yP2cxdJ_X{Tt^_VH&k z-U&Kn6Jplt_vL_8b|ysv34>NOraH&6!}fHxU}Ky{q6ozlSs4)3iDT zDiO!rI?J5*oDVXxvqMlPN*7qm7$DVye}~gr{BWRTes03E*%ViH14o^!kNeF7hg6ra+APn9Q<<1d0LLok*y`TG8+%7XsB!ACdq zZZb2{4s~6wGO?iH<)f+PKKy0Y`4lLVmU1{5xL~tT^~%Utq}LV{1tQ8fg>~h3xaolISvY1ws1~xsI1QRJB zWdLZwa}>d=c@kz2rg-WVWJvlyc0Cj^tOK=bifyNCx%RsZoT?;0J~5}cXx;gQ93EtG+pL$3cA?@%T16-dVSlGp#UZIfbu zhE99$)SLf%=zq>;`g!q2sBfvSM6*okh|w%9#O?QnaXcT#{R1Ho(Eo-&s`L7_wUOq~ za*|?ta;?WQbF|N)BJ=737B0CbgJs{UXkN&Re~*;kywgVAPIbaOn?(A#botQ>fp~M` zM@5w5p$;KoN9d^BYyp{uNV7t^@2qHNHd-77Kijd#NUo=sm`Rs=)hNY{7C= zbb#Bi$wQIgpTPS=pTR2G-JJ(Mq(+z8>ZhhD{C|axIguv~DT}3V$dlKHUvqNoN^!t; z9&z|=-iE^VPnwyvOE0Z_@Y%Q)KnP1E%qlIvq}`qth=Ut;SgI0pNWfl^slfZ-8@G%FjZoOM1U^%5hQYxBD{WbzkRV?K5IIL(++8LV5Ld8yr(LCWs z9Zhy!%hkkyB6dyBS0)Zmx^GC(Le zYT3F8!x`oQM^x_M&1Ib^KP%|o?n2#`BGqTkMZsXCMAzt2X@$(PmF#xy zyzKW^P#(2J%M8S>)qys_$WVaojYh&5*+pE*ATs5KP*#h2g~Co#O21Bp3WPZL*(sTV z1C|gLtV!^plC#~rBa&fqj}lse(gzfLU8mWdIlhMOTsJ?V;h)@rU)OJstoI4{IIq$` z>lPFZ@&55t(6inEJCbdj(nnq;3rbclm{DOhorLW${2r!KK|E}T~I#>C<4Y~q=k zbFu>%`A1GmF($yJ`G^N)7H3CSM1)2sH$&B%o0I3R@e#L><(|EA8is&A>u|iWYejWd zRse~a&W1t?^B#3@q$B#8?DiitK%Tg4cYG5ekHH6B_VAr}U0sA&Y@{{-)wxxrP z=c5EK=(^B;>Pg6zw1b)MfOQPuG(57$FN7-=7}$^AnFt2dMn*O}+4jG1fC5286uq3| zQ5!Qx*}>u#TnygR4#@k%L(=y0)#Z|Kwcwhoip&yJn*>(gzo1MB9gWn8C?QTpuauA^ zX?^+(cS{`_W&>7nf!jMrmX8J5Fzq%G&R>w+utCP7QE%pU(~4yY7h|=A=_Y@t>=m_3 zTzQGHvKrkSzEL-D{mfm2rG{=NSf{#j-dQz0Pis1=$H4`)5fc5%YR@IE(a$_6xFL7w zx3A-ii@=XLBitH+*=R1anb4wELmt;6t!}6p_uh7GCVWm6cSX@O<6qoS|8hQE)<27( zCHK+INk^-jmxrO3+6&s4I0{@i6myGr$UpE7b*m7ja_iZ}Mi(pcfP%3h~C zb&Jxl{bO9Md1pS+XSt5<7NP@l`nN-#Jv%7Qvi0owai9spqxd;V{bL-k$7MR;Hbp zYryV2InjeIbI$lQdh`dYp(sPf)A)z^!NboiNQ&~R!I8=MchQs{QVgD|3h3WU(@^lc z2_&T5mRP7UlwEz<-bPrLXmZ76h-8%GwtSZ-?RYh{s5usqP}#d#c^^(?bH(v&&nIq; zR;5P1QOn^lZ+)6Ipg`d#6J>Pgw2lmbxOT6|tVXAo3>`?htaghIy4X<+e6H}}_1uyg zREqUD%bSQRM0(|y%?r4`y4@jK@`@PA&<+;9?hlru;wKh;qDBZ<^NMc}urM;KU+L9~ zN6gd}BkF#es?W^CE}@LOLRX&CIN75dskpGN;u5lzxFXRw_bmB>{j%f(NG0%e{pCdS za-O;PvycA-Z(<(K?0n27&!SKX*5fdomV@)?Yue*QXK+1!$aoImZ<)!}l^%7px_x1& zP%5(|OogLl-y_6=#l;xcJ2hauE36<9w9=OJ=*IRWa8w@X@%*83IET*`)fhkTu#TZ^ zP|Ot&X?zS8mvbrQ_aQOH0pey`jmDhlbvaUbY+CRJY% zL$o<61C%!Bnr(^>wv*ReHXip$oT%irX@GG78;{6WrXNUVXm#vPS>6L4F3}5YZyBQm zbkC1D8FS=Jx3Vv>EIMa*_<>iXx2u%}e{>U(C_H66z#b_J69LBV@iC*N&I9H91093o z4>)95zNRehVu30@Tq&gyftNlyz+6(;~UKi!m8pPp*uTF1-NyY$#E~elj1+naORb>55+q5CN>!^ z@7KOFvGjyBBEFggpP0LO?lf*OOe6svpRdg`8DNJIM)YlBrJ-CQ8}2iHSzc>R zum(pGYB!hScKEH95PT3`mAae4F*v@L=_CAMp3+NIqQ6kssGP90$DE4h|7m^-_|IXi3 z;)wBu=q^l?F&%X8<7L(eA>2lWId^Ei6Xr9U){#%4kx1F&A(SHbkKtEA1mN?g&)4{w z;b$DT46Snb5znp`IaH)kRP%3<$J;AERVCPtyG5y%E4|OfetepF^xd62c~QI6AS9A~ zf*4crLc({pj$Ni_Zhxb3PyL1_$Nmb3-FV`PQ0R{JY(g64d#SO6M~|lAK9;n^5u;LD zeo_-XTA3TjOAUc{nEIK+^s`uxUeE5$l?EJS0j1vE$bIqO3NM0Q?-<|%c!>Y1*$HCkAuS$PWQJJQv8?rP0pQ2?6&0(-*7+pza zm@1rD9ip6H<`T~jMhBw1u4vvb!V{+R!9{XE#jllN682-Tjvt+@y-l z>-%UbSrBF%BR~st|F9+B&WI)7c8c6wE}sbXUV?${j|3Ed-gEikCkY~kR@SPiXXKXg zNmFzlRa@gpa&slRS_{#4N(NeP(PhmUmtGuo91%ki8VSAzmIjK3`kzJ|uHSeU*n zLOGl%jB4BCxsP)1YSy4Eg~Y58PfhP`wI40iZyKy*#?Hjya^0!)5*u&)qm<8S0VtZg z88y{U(>}zqGjd7m?X8~x)5bU^TnUl&4=Bv3eQBi|=Z^|O;{go8PiLbF?pE^J&3zB) zuuDJPwlxtQ^{^wV+a_IdH;`KIsA49u)I4q0hO{`$(Gd_xQ8QDzvp2h#3S02EsdJ0# z-|u9Dh+5;KI=y6|YzjZ_vMgQ#@WeMFSg1ndzHp#`6t=uO3%Hy6>`{BVbK9>jeb7Ye z?J&;yd+>aFbx5Too>Bb_E}isEv~jxPKD^f1t3`p|s>uL6T1d!B(24qG@mE7PE*GU( zO5A6f1PgVIbFfK2d5vsbvWQU(d9A|Hkf@DXyGa$@QU&eb*SBvq0d1zqwm9GMx&~pn z?X%FpgY#a2UZcP>W;Fue_ruL!x6H&H3J*yiBYn1<7A_Q@7LXF*v$Klr10T>r5X)Lt zNDbx?lS{;1=HP979RKRO*~BIlP8pNjyC%l$JH@;xxi%#A^RNwURuRB%hO-2CuHDFN z7@Q+71)}wX^cKbd?830Vl|}vvs>O0J$TQ{?1F^)<>X&Z~xH>|7xO9t=)HCD}A@ufS zrdU#W)5T&KDsRo@5`zloEP``Wk*}hQ&)!WZ($D$08gx@A*MH{J=o?=7gWs9TM9pC z0$Nuhu;U9ZG?sw%K_#cj0v5J_C&oQv+y3wl`x>WhT=1gsGd%jza-Zhz*&mR}x0#gn zB94X`)6{9dR^%R7Wd^oX0+7h4*5&3+pAP?{p}fte=2c|A4?6$S&3_P&k$sufBHg%= z7+{6^U@PSSp)})NCfvC7cknhbga7A+LPPXdD6`>h_9467>>QCI?NO431CFdVs4khY z&avx|>EA~qyOl!gGbTib@5>eqgq?*HxQxF$JLx}ko5Op(U3Mv9jmFS}NIHei8zRNPqk zz)XUp{-|n`jw-v}O5Og%=i#zEjSH|p;08UKW_= z>wKG(f!vim*c-x%CS>bdnP=5`Lqc80$zRr4LD5XwT9{*J$KZ5nxJC$;h&_OID)Ss$ z%k^K~Ft+x-S=ohO^wj{Aa|mg-_ClIC?0G3>G@3c6fS~hSeO}jqLw4C^&L#r?Q*8IE zuhZ{v(vz?)oiq zsU8Kn^?Y*^hGyBdZDD|@+1d{Nn~3iy42G`$#GfFxC;!`-qx$84d$XAy_j>#N6I%Aa zUK_{pu|NO4D)2iVH{n_zaO7ID3%P543{8&1`{G~z?{`W*s$=U{mwW?mvON@62Ez@6U&RJ=0-e!IU94 z1%rINxKP_<$1mJ7sqRAzeER0~rdO0Gs8p@B=QOSY{{*9^I%2{yUZ&i1Lm|>$jA?|( z?2P1WQJxI$uyC5Y0#7dcW&rX#Bd(wV)Lb*GP184U%OiDS`*O!S*-JY;j&6{lbTBN= zonbxZQj?N>9A{?*tG=4mv@&T=#89F|jW*Hl(u-f0Bt0N*(eneIeRj{5m_v8Cu{Vv7 z0(&eAAS41qp`;btO)mxfcig0?@`<|w20w5`2h%SLg8O@P{E&bK@`NyzW+-HFSuTm- z%R_Y04r#W~p71`N+WN~Uvrdq`NLfT#$dhOsjuZK^^`*TMizj*^dNW!)AX3a6)Tlbf zsCWPoXSV32o$m3;Rb$UXGs&To>{9kunLqe7Vljg!ZIlv)S9bQ^H;YN~?p8^%C z(NnYDjZwgv%?yRxn!aWI_WIWMUTCw2xTt`9cWkv_zM!3A`FGv8Ys&omHx07n8k9H1E}!W!C+he2zW;Gnzv|sBv6N4V zG@(KT?p}IkTwu{y#H3ZZ?v3Jy7S(SsmqGr-r|-$?+e7pMADt^_c^C9L%xw#lP6cbV zY46lHr6V>Cd$XpXWas z^}f}J=zljw)Vue>A$rk4E5<7KnZd?G%^#*Rkox-|z>^vqCHU+^t|q2??`t5iF#scz zRPbPzTgj=&jCMZEpV$6NE7L(;=GA}1^}|i7hpvBx)Jkd*=M&ejR+W6RCK7Cwq%c^M zD-hfB{|WIwfwpNf{PhwRsO?@RQ!0$xroCGvPWE|FT8gc!km|3obxSj=(}r_FL_t~R zAH0bKdKk(xKO~JXi-Yvp1wGpJqsLNeEOp@fGxTxCGxOt?aepXpz{F*S2))^{%r%(k zU!0*rg(Zfmw%y|70Cc^U)b+VWH=AP;`7dsG0!=j5279R0x)a=`75~Wh ziMKH{{Yn|+o!S)#eeY9IxLUrxwk}B_kF{S0L~sbM|6+-i9l=%Q0G zU}DXFTZ!nqgTIE1{(B^5${R%9Uo_D-enM{U@R16$9PAHDp|t=V=kxULiHLl!@a^ zA`>Mce4&fj&w80xQ(v*dIR1(59kjvq&NCnuvRAo&EwaCey}>+OS=pRdDOgdt!&(_E z)7;MaH^fZjK?2%66)ZAseeV;9PkYss4?>iA(zI9XvuPo&PGsbVvXaxqBLD&OmN~W5 z-p#~NAnXkGOEp+B)?~TYuedSVJnLL)1p6ekGhDfMaM0zMg|`%sj^yp_eJWxzAXzH7 zc}+r527$1B)sX^LfOS=Gw1{H4B#@O|@$p?Qzp!9Z_kz@#BRL!rHss3)NOGW~57$vRE{!pG-u3 zeF6$n1AeK*B(!K4FQq0+tWJG6*bXecfY?OTKGpg|Y~4&%fu^YFWGjQw91rhJbVE?u z0sW<0yFGlz$k#UVnp!&>s`t_{8gZw4Wcl}K zkqZ`0dN1zg+{<19C8qB|$?9^lQQ70K1jDQECpO@~r~6==w^~*e`FP#r1~aSnkH_rQ zW7B&JFfcVt-aZ}KBY9q@?$dMdbwgO)nf4FU_c0(9ef+a?1+g6IP4EF%XLcM#Ejnb7%TgYY|Ura zOc5m|69)&|`-b032cCf1)n*n-5CYh6us^^Ztacn9HsSCW^_pS1dU|h) z;dlE?+FQdBe}U-x>Bf76ktm22sl{ez4Fow$17|+4ti{B|bS(g028MREk`Tv!cEjYT zt_4_y(8@{#+>7>Z;QU*a3o&-~2Tn<;4&V`AB|DytUqHT*2)+TSJv*QNo*}Y)_|P`+ z@J8Zo2G%P^A8B$FZhyAsVaOjotliR2Q0Pg%4;eC=IDfiOz0GF}?IUgbE9eiHjwhag zTr~&9tNr#s>3&m*jegsy(4ANRG0Kj`8-*SaN4ozPsr?_QO==jreHE;}`fvWrF=Sn0 z1l6l;|7MFEemusW0y8n)<($kAw`1AqZw7qKJS*cT@6B}u9^U@vuh637`v1q4FQ@Go zz>XdyyFh-|cs)+P`4|A6*b!UC@ayu86A(odPz zDMA(TDYp98IJc0O(A$KBTNz|l$dF!dn`N7rn^{}Ei9Aw{PkZ!x3qUd=Wq6DoGAn@% zYXMbbw#imR1;K$Oa130l9>N zXE*X*@MRY4TRy*5&bFxQazIVfn!9iuU1&^D&65|ihfApeZhqPyiox+?=)gYR;eOM(=tyR4$)Ezf z4s0L~eg(!>2v#9Zw>SI<`Z_o?SI7`woI(-Hd9b56z#TjE9(AD1Vrb|TTr_igv_X{_ zc1y^uijC&#RJ(bN%J~%Ga;z_@0_D7kFbr_mcI=m-IA%`5;|30wuaq-J))?@Fri|G0 z@d>z$jRv}<7hSB_(^6%Yvr_iw@fy!$llW-@W}4`SSIBGp&aj(7J%;SR|9k$`yp(k!U!G9#Ux>jEH`Oq73{6CGA)kpO!k2I{N*qj) zyl**5YvQEyI)Bgf#lOWJ1$ujiAG8pQ*8TlOT_rQC`z6nGFPMG)Aw9G#ElR#8mZ~|- zCuWItCW*2%%B{Yh85sVU)xo320$>ET2X=LwhVc`djb|jDXSDhJ9MWH^l*?Gl_(?^Z zH3p=dneIHk3gYn56SWaWk(l9${n=tQv?T_C=){j$U&*Y+vhWM6A&w4zmKA-Y>Voc` zRGgA2Z8IrPOa%(>V+L|8`)5B=tPtMp>nejTh8VrJ4Pw68<0cbm6cAYQRX=6Nf9~hV z<;*$~WBM<2G-A_u^>f&|?rwq`p-t)X2H_2}$C!58&JfK5?a5Z)G^Adg>wr?q`RkWW zKeWv(lb#-ijaS7Fy`be$qvz-MnVIq+w1m?e{@7R@zxKcfeJ%24_q<~@iPA7P)VZl?p%Aoa#2PtG>)@*J^N@>>^$$ybI~PhA8BDf zIRjUfz>Z5UgVOoP**eP#PJgl?t?1@YxV^X4Ga> zN`-0uGvLN`HGTo%v60=`#2fv;sD4y34ldy`|G5?nKph@bv|2FtXO;V7F!(iJLj9*O z*v|;hna}BLGrzV5DeZapd4yAA@xu?W%xneh9pD@nWdmbLQDL#(F zqSOy6T?Sw7M}PeBlu=OFeYI`S%z_+1f*)X2j0J5Xm4~vmoe;*HAf{04Xm|8aJsxqVQw)Ik6D8P>ja<4Hn ze)-OV?Kp#i1nwf^T2th}Wmx7p`};Mi)TyV0i`Nc5I$ov-rZRL* z_JJYKUM2=LrJH7d->N@E$Kh?eC8zO=L$N)Yw%rBk%(gi#S?6(o^&3AnPYS@gRtZUgN_*^ zdc-Ld>PJxtAGnA`KKK`@%X4P#|A=nJp^h+pD_Nz1v5H{hs{5s-rR2}14id%WHdZm8 zE_#t-?|Ux}y$57^J#E*(riVkW=#_r-cqww^Cx_ptd3JD-H>vRf{j9&LZwoZiBBUvn zQ$}xr)T+Pn@IDt1M6F}@ZFxEWEI;`0iSF^s+Oj|Iw)v9U%pO0^%||w=1UJLbhaC~O zz{{w@T4DSA=E_Gt-_P`%a%(<}SKaQS?h-swt&m`g5<|k6(*e{^q8Rr1lGW;BhPz(s zh~Dw3dOc-9vbq*dPfk_k!e9sG^EoKn!~0f1IdD9-IfY$|nLAuZKf^5BSF-GnDf@d< z!@`zGCX6r-Pk`TqD3n6or7XSXwjFnsosSmraok-Gu>bORrxqw;N$s<)xpg@XTqCbL zeU3kP+_G(ZIxEoG@MeKNIF)VkoH6)mQ&03LdTSY%c9whe8lUPVowU3uxE30q4q{lHdY}y9(-sP-m8{-733Cv(D&Ghsl@F{(T)M4%?sVpUzZBa@E-koFBJ9@c*J&d^l+AL` zeFfvUx)o>7sD_ylp-35^56*w#^y*LOU_u`*MXoy^TF)WWVPCM8(RQG!{M4GGdJ6|` zWBj*P%M|F?kyaPAk^2Mjv~kE5<-lj8w%VYXm$igU&}f_0`?hzzZ8S*Op4!az+4_}8 z0G}o-k*0kiy5gL}pX9Q6;R>qYo`|1wb2M9lx@_kxBx-A>1NQeTfUVc;zVO(og`@P{ zRK^^wro)^t@iIS;30FyuCbafuct?6eKpih=hrk)~WA}Wu2^D5(nvwOyLhUZA^tLlQ z3>sZMH!Kv%$)DPhI}4iKClzhmU4u&4;1!;Hl@gc7@fJ*xv0!UUGxA);@2@R?N%!%Js(8>rGx>wOS4C&A)yvknsg-usQ$E9==j@(=-bi z8QBmNbz38ynNBWX^roR^dO;t!`A1rMhF`1zOmgGe_qFZlI8AmPW)pa{LSj4IQ^8=^ zZFVEUi~2X&Q$U{tmalNDLU!qbhQtX+CFF#sD$&f0AB=i|hRs+@uxJkaLgJ!W5V0z- zzVDwlrP=v?WF?CHJXj^od)w6njBZ|zBShQ_TdbbK^eqg_1n=XCFjU?R%}QIriovx{!|!i2BDth> zn|xcE8)w*Nn!LfANf@D_O#p9Z0+KRM*R8j@slMf~MHugx86HR%F`gLb#fzWWBOl~CiW37Agj0pN6wE3Pf8`%axughBKcnXXpXBET zKa{9i25KGu26x^9vQC3#vsYn&4?bxd|HUA9iG>BRy*G;c9!5Ck!67Zglo@dI+!Y~W$7cqA|kkp{c2fH z82+04u)qB5-Tj~F$+`gNS$QpJgEm1@{t0i=Bd{V>j=R6VT7|YIrUASR!y9=aprOUu ztFuj%=#P*F?=XZfY}69^y`{I|G_>24A3!I?vGYtOwxME~E6}yghF2KO-UR!rOD06> zEv!KM+;k3G_Bufy=)DuZEqFJXyK{xMl9xL%kp;69u$i9$+x|)KGg`T|)FU)%Wht+n zF!~Ao81teoiti~S@17nx`GU`5(|ebof!`L=ZdMHxI906nnUe6I!wwNhj(RnJz%|6( zdghgl=rK)($Fv%0jC~u{NIWX)W7=;yGtH46az0ej;V@l77XOCNXYNk->TG_TQCfHY z2mzURgS<9$Z^^PEZ=>-;21Ak_8LVNCskgJE%u~7=>KtP+irGvd+mE$c7tg_N36{Aq ztK?6QFHGsY;yNUG`VLXuGD3MYd?jq!@5auc8nvsb31NhfjrM0=h(pE`}I*K zWHR-nVs@8iR+${QiDW*AM2s%!?Mlrh-5`JML-#(>9e$Yi>4p`e9hlwQ!zX4}R4~ch z;Fdt14)>qC>7d5t>wb!5Q^+$hg}|3Tj6<$&CG zBl1n+)S|;SLDt|!rOw=7M!~Bz-M!{;iqoe{PMLnPOfyTGZdiXlg(I9oR2LibV~88} zWu42Cm$c89oO}GfE#zsxISetnW-gRwb8&ICf`qDgBF^st+%Aq(q#DdVxotPsPTd!< zP7nBr)?tZK0a-m6tRU~yo2TrANy<*lS0;@0Tw!p&ryMTJa>22sS`@m2FGUoWJO)jF zFjsKF-5YuNzLO;+WC519O}q&uK#_!krrozIelNB;nJ4VVYMCJ zLNBjfArSuqbj#Cw)lc5PHY4Hw;Y9AcSXPC}5q~*$Fo(cXmBXKm;aB($fsK18F)9%R zgB9G;3)yIDm7)(qniw8L`VxxrKCn{*KfP>z1H2f9Hs2ha9|E0M&`$SVd9yk!<88nZ zQ?zyk(^PXVfiq1^HTSti)x-PQ6=sE8{QM(`o`q+QZ{|ciZsdhaNZ{XRlq&A+HRHRv z>Ll5Xu3kjb*4W?rCywxQeI_xat9T&Okmb#)68$B9>-2QcsgaUuE|~o)X{F(+)l5GBrR8BV}B2UIRo@`{s>Ev9veTgyH5ZM4+@6D>Rf@ zkzJ}fi!#c6Z6&Pg-m3_1>?>T)GA-e?$rnK+5<;gdlbi&jU>i7BC8S(86 z!lR>m+0NjjyjJ>7lxwxqG@sunQ3&ww`;|yWzZ^^qQK^D!!qs?`yK$`xXQ$$hs8MJ5F|X~Sn*zn`F1KCe0j%=*JAGa)Tm zd&^gn1LU1BKAY|>CA{oo?*i9{`;-oEss-Rj^)XdBcLU=%O7Hp26lSLg4$ED#DoYSo zIu8+bn}hmc6y-=|G&Wt1sbT}V_bjt~!Tm8IDLWN@+NRria=Hocy*)g+@|k{#yq`?+ zg|GF&nzq-+tI{_K6?hZ!j=3NHWrba|IL!y$JP{-gpT1e#yImYclsR zgeRP1RN8kX%dJaOrCm}KpReZDDtYVcw3AbMqoV5$sJnGL3vq^uo_Q(^oMm*~aSayS zp*waY?M(4J8&$^r@3b+8tK3U+?2~RC?RA`y4;FT3%~3{%PQYp)30JA3lq_;Fv3vf} zM5;>zVX19*Y_!g$j<8R1frH=LEz}Y6objgHfF$fGX%_LOvv&W>n*g_sr)ncM9g(Fr z?#P`CakZAZ{Wf>~s@F|L72gLcwK8KI5*E zR2wYRaM=sMRg+LFyM9;0Tu~wxw(YPfkqNK?Wqt9&p{zk>UoY5m4b)z`UO^g7Z^MX# zbzyP8>9;N}!^4)y4!L?|j8lo1dv79G#+Yd8NzIRQ-uWEP=GP|#KGYidD~AuD2*Mn< zUEd=WiM6h&bXUWdW&&+*C@EkMWn5Mu5Fy}2|7S3^7FzyAYvn^e;c!ofQH~%8ez9Rf z0$}n~s`in1mMyc&BgUkqna`4r-P2)h}GItrUfP zRqS||`$KsB5773+%Cpu}P#550kEXXTM_S)BviR^@&J)dYsn6P$S1DiHK?MNoD~}1{ zzr~B4-@A2dxZIo9EUr`2no>$&fz<7o;lB|%X49h*&7_Zyux|#*Sb*MyW%jv4ugdtMd z1d#yI6#|9QeERgw`&Pi>Z`~^BW}Ww32c?t@n((uHUiGAjWH37_WOz7kpCD~Eb_{y` zs!#>-(2wz`y^|G4ct4d7t zEG;2dmsK~o^br|4d3}o{J}LzD@hSPV<&&{xWli5awObw)C@XqdJQTE*&VPS=6Uf;< z4}!9G|L|^OApoT#Dv+$638b$`o+v}sHUrEf_f+d9OYQrDT7(_OZth)pV*6h;?@VO) zmnUp_W7Tms05X;odrb6u3Y(kz3~P(Q%xL}@e4ZHDlMkw@DRqvCS7fQW5hXLHS4*(K z59UZoZPVi-0eKw+F3x|tzytRa4Wk<#heyv5bHX4qT+>K@{Htf8tF2B2MXK#IVv=w% zK#$}8Lyv_zHRJc}9A zCxl|Y?4b(iL)b&o&x5I157M6A>_jIECy zu3Z<7de6!@NDmT&T7>>{9nGjlpEwm$(#)M1j!iFly4Q5n>2B~$D+ahX56AvXbOr@b z_JH1f3H|;?W1^=85gUph<5?k62X5Ug1-QSG|%fBTCj(h?$!H z3=DBz=AH;$M!X0;1*MCE=z=n3FinN9<&#f$RY7iYfZW4?roWXw*)3ms0xg>j|Lr77 zDw0se!NoSpx3`GMu^I+-*fk#Jlg6JX0M4&Yqt~3OFKX*)RGZ^Vsvq}iX!O3#dK)w~ zMP%o_WF)e9xxWRm3fFv@N;_vT%cRrdFlbgH2`l7sXxII=ko_r78=enxru5jwEO>2e2kdPr39{gJ71aLB zmIr*n+c0?eu}B)WJyGwJN@rARreaExQiJKUd8!{Z%KGAGC9Zpk3+1-0K0AS&3vMucpiHVB*2^y4q>WyUg(b`8zJQW4$6rh0ue(_H3=QWAERH|B zhRM=kD>O)0kuobZfLTfCH2Sd*^~ok4h7o(mg(sob@Azh0WR?ERCIv-L^O{GC{bxAn z(TAytr>bMy!94B4XITN3*w8OFM7P99t&b?ee=PYD1jK!!#7Xd$3XNA!8Ys+jJU4gG zqI{vl90IM*K)N0#YdnGbZIl?KWXGJYt;CtH-6E#-Dj7?l+M7dcm45FPVfOx4)tP4U zwzIi54Nuj^L-%%bftFh_>G>x+{@>a(9}2p=ZIDif!>0thcjUMZXpuW+Gb;?zDRe6| z2v|NMuj*kWJf^)ndkM0k=za<$(PK|{4+S|sc!iy&dhJ2mYhu+|`zO=8oH?9}u(nB= z@z;FXW{e!E$td{qpTO4n^~M4GYQ?*=n!8mb4zl&+g>esaCx!R#EDo=iy;lpk($rpHbFU?ORFRoYqnsC6yXNQvo(W&}_pH=a}Wy=o_}p zFIDHDKT!n@Xfwl@{6-l5;&?yDOGwES?^WSxrH`I&6nvB_%=~fF8Abf2xVoYdl#oyma8}UKC)G^__1&pW?-^cf zHTdzeQV{xjfyzQ(rM?o3^kp%PGg>Wob??(7Q4oh>R8QBRrOc&Tx0&IDrLkPayX{i( zq%d+biW4L`3>p9QCSQz7x|D9YBYFs?kp$9kt`I68x8>~i3&Z-)QmEod8{XX~hHU|R zeOM8Jm2<@c)~$?IWVrSah5>{6=~ACLCfK*yTOzQ2f~fhM-yw9)N5V?&45lL0=%x7J z5m9nJ!D~YZd>J7REPA$!J#iH~u~I7Mh?1TDY;jIwsu%ER_)&)-Ecx8Odpp;u2-`B? zx+cl;9tK7y>0B^;vju{^oql}YCWGvQ$*~<37qoA)h%Fh+^PeMtVB6p;*a1P+e0oIB zz}SfC23`8h6Uy>heH#1OsLEM`nyVMmMyQH#=}vq5jaD8ms%?)9`F4A3SDfTN1cQ`9yQK$lgfI@ zpeUL;mEf^f<{pOr`3y_G={ zCi+8Mep3)Fd|xH7F;{Ih4x=C5@iO9OksM4RV*P||u*SsS5tiS5VF7#goVXnK#pQjTjiL%e-5i4*LSIR8}9YAj( z&OWX143MD~LB$=5acJ4cg8RhB+kV(s&tXi8#w}Z`g!lr6SXwm@;fzl!5XKC>>+XjN zo5w^~`t|o;uKD4LB9{|Hy*G7jhNtb3l8fFGLVLn{hhH#Fenwl!R!j0d>27Svyo%C( zucOVjTin3T>+S)}{MZuo!!FNoJ%VZCu&2P|dkpjbuVpeq8wqaHhqxUh=Wcb;rSHVl z5~(G6>NlnBfBjgIavAS=-?k*K1NmESafB0d->jGE#@4fvtkUK8u2ezUJB9xJ-@g|vc{Vi|fDOZ3`X<&Fj@cP(2Unyb z2HbPyf>Gt0-lh)TGV*kTN@=`nH~L<$cuvi6D|Xk5suP+E4jyV5w1G=ZiMt*Bi-QTT|(B;k_T^K5iE9db1hBtnhV+(u4Ey%DHQEhw@AbEsD zE)HkMdnIgrCjmD+JKc*3<=9_MZus-BHG__9hxCSC@GhtGMne%Oi*4VNOx|O?oCnt| zFpbRW*1r9{k#1s2V;u4!{-TS-Ia6%hK;^}5o$>wpc*`^!0vHOuQg9Ui1jYx0%eDz2e$Kx%z-0lEuMpi4a8-G2(5jNn*y7y!ZhY(pJsm(bI@`+ zMSlpoIcm%gWp4M)8HT7~mu$-%BlE9$3hLf9TWB3R=^fMB&(_o_=BeC69L@jm^;kdI zU+z6|)xCA)ahGesWFoGjw8^@Fdq67Z($-cEYvXbu(Gx89zHM4!r6byde!}?V;AM(j zkhkLU4N0cJk=$@O@r)mtw0TtB##v8#dYVmmq6TcDpyuhE=eaFLr(cI!ZuZyk8s>E( zF1d{QY7Nw@maDCJetP}oV@BO(*`&KeDYanX^5MPiL z3s;vmW$}8Yr*l>;#JM@XZ@+g^=T+iR<(5I|UBD z2Q*(0P%Q`B>i$U~sal_s;z1vU-kGaLj?|5n?Z43})!)v z+pt#!6r~p3QEwf7_rfE7e9CcT~g|ecxn(aYwa(HX+nrc}e zRez(KeYAUl@^r!mvoNLJHCUX<8{KF(xVTf7+2`TXpCcTf{iLO5{?bs3jZphEbV>WZ z&dk>G=D}p22rF%_u%o0Z0w3Tq_=W*v6s|%ho*e{+?Zmy6%XO#*4M7$4US@T#j4_kA z0;&qqK6Ky#`<1_}ZC@b@ChnG&HbFwG{qa*nOK*~N*hXwLlNu0<^&6f!?lNmW=aynC zjYQm=Kc%_YMK^($x#;w_K_^ORugngaB6;Fka?uc_TD^Bu1ZUT#qK@0kbo)4B7jS=j zu{Oxb^G&s*p)ND4et&(WjBe79KEI@q)_%gY99=f0DlQ`4tapU9Vt_%Jv%b^um=__q zmyQ*j{#~!6IOj?YD{FpBquy84?DDbI3>PC49Mv~FP#h_;Z?~4j@!SL2?4dgeYe-D( z_@Hg{e6WZwuJdZM=r3A>sUXy4Vw&cCXUcA=S8>4=d(}BtfJCq%;LHv z^J-8g#r0qJ=T0gOLnI{-{7G0PcJ^|o>5}>5EHIENpKzPhmElaw4YyLqre5+dH{w?>zcKEo6~T2{D}4W-*Q-SDJ;!tY?_XJCeij5ot! ziANiZV=Bigu%3dmK@aPWq)pSA;@X0`^}lOwf8RL>!djANp5uX>Y*o?gN|N86e0J>*b}u~NDIK)zx|zIauj1qz$H zDOLbUtlkuKT+k@)%;pafQwSWi3)D#2{4VP);P_J^&wWkF8#(nCMYVVJ!f*AQ1KqQ? zh<>`x&U6P>*e>NLZ*BZs9Zl5bQpE4SHo#UX+~{R)a;u{zI-7~ql(4Wic_g}s8?h0o`(39&CZBi0;-(+*mWTk@ z1+)GP_qIP>IGT##IQ8&MMeqvbwkQ-bl&RdLI%L}Im9j2X4c}|el=G|%LwC`|LQ8Ww z>Vt)WnXU7evrTK7Vt=n##dQAYj)Chi`s^>xV*>K3_Pf6JuUC@DkZ^x9_N zTQ(D7&+iGksB`R1o3mg4%#1|)H7Y-WLQkbCZ{}8u$lCgVb>PDa55*hZM%F!dUKCO< zN@-soOJSYl=&zlRwLQ3D-&Q-{0PQ)H**pvFG^np{3-+uiy&I&VSiPHXfAL(pW1=;S z?b~h=jh&ihl3t_K2PN$-)PL!IUx?x-s-} zcZh#u+9TDP4L$I0m~U)9{-nyGaz-No`FO0-iv*@U5Fm&^{uBupoSh8ySV_EUGv1Oi zBc!VwHd<43%(owv_UG=pZ z$sZ`>c)5+?ZkyoVogk!@HM}`WhZ~h+ZfFzl;moL7SEU(ae+1=^r@ojsU&tleE?jTz zDz@@OVZL^Ma2bzuKbmaXUo3cLboXgoy?1?8tl&?FJIRs;Wz6a_>z=xoO>pX$gO)N^ zK4_aKm^h_YAzl5-XR2l{pEDoJyg=1WQmOrKE`fEfcb^Kg#3IAYdVf908@R=<*j-m*XyM#y=J@MEr@4BW&EYh*eVU97+mk78 za;SV>&E#9rHP12)HnWA$8^;k9_c7A9(7dSOTm~q_YjMY6TOe-G&V>Wpqr0C>3&Ys! z;s$~#lmZ3TGCM|$r%>B(m@uY6K?p>3-#e>-Xmj%cYGl!zpISzt5juRf_=4|!UNLerX(J>3o&nyQE*Z0l7 z3DJ&{s2W8H!~SOE>1=(~EU1-4uZryXzB7Q_ZB|k*S*+w431gCvRb`Oi*9eSS(;kC} z)|cLHNNT-mXO#zcn4VIxEQ6Go#wAL7UH=E`S@5Zo#h8jH&YA}<4_tbGkvmbIclpK6 zu&|}yd-Q-DCTRa;U8aAhvTNz(%IXr|FM1JLcF}wUi6Y^R8e+nbBXvBUJUDTjW}GR3 zdHpbic6;WXDLcE6H@-=aTd&e!)dM=z<*uI0%{1AQ3raNCxhn58wvpJ`BD-vzuoN81Eym-$4_D8_cJ1G z2dVP{*Dzk{FpP}Q@Vai_iXCtYCDe`0FKIlepFG-FlbYN{)j`Mu*Rrxswc&fTErhA^ zv2nd@fl8gq2pQdIe0YI!K~nw~@5_CirfO;~?yx(0^HL%_dm~O-*tbAa#q>2JYU5{E zk}4`Ttk4wZvvj2b$dX`J@-7I+XT+_?WTmOs$~%OpbH`dN;zC&${CeM9GomI$Cz6Q? zF_iww574Hz#LhzF`#;9WAD_I?|Hl{^2FnikAK{aKqZ%MI^5;*5zy2E#dHuhwim6ax z(TX|I8S;cB=Yz1&e?vQ`jYvZed%%x$qdN&fb1H%`?GK<(*f5BtNP}Lqjd<^T5NtDTPYgK)-PBuqN!LM$y`J1=`V ze{ao{sgUn=fv}%I9@}q0#HeVYrGP`tWd=YL0>U3`?O_^m=R3*Fb&AkO9o-tz4-f{1 z01Q6jAL9z^9v44t>~{>J#UgKimG5B9wB*w)Ki{9WBpGFdlt*YBpS7yzjC`DO)gWv* z+Tpm&6*t0G!kuwg`suoY?cX4TcdNH!5G}B$I`SR>W}6RF5!|+6641)<+J)p+GmZ`C z`#Ok#Lt@K(qJDnvnj2AxIWL<^XqEXz5m=Ff-QT z7Ca+uul-3xjvVrAI-bvDG|DeO%1#GSlNAOFgW1Za-I z!_^STX&-?X9!q*g2Kz{_!B`ltf(rU*FNr;U7`OXxajQR3r@;m}9% zM=^`RPC=~!EN^QnX#B3adabWA?=5P+>dWHl<718qj4sWYrR5h6$;!70H`>L5=7w1Misk1w#D1PhO^xF!SJa?{>Y_)u@U8iVT>&(+AYZ2qT+dKpt=X0GnqLW zk_CuYAFuNvau!=)$@>wuYHNALL%qR7(&F&h?P!Gp&RE*DgT%TD+0*|O3exD#`n>!% zRQoNzm{hQ8f}Zj5JKs|qWqYj=YRO);zd7S**HCn8^KDHWz>@hrAdIIE@;jp#6_*-` zbg&mFkHgX^m5RjYW!B1~Ur}P+dbo=+Wt!GVNeV@U|!O0Tq;r8Q3ny+pWG4*dW&$2LmlmPTJ!wMLdfgudp= z-sIRekbK_%cp9>W2wUkcMuyXe2xpH~bB>Y2-cz=ZEVoV86zH+Mi?k>4c?48lr^D7a z2RHI4A=dP4?nJ~$Y7I;y;m!p8HS2?86;iiAAx=>7wCw6XBhoF`P`-X@hvw`=WR?vu zIu>6|P=&{^$(2YLuE9&K++`2sN7Ybg6qru;f7^++5QPf`wYWBy)t$zx~-U^!S<8n$}<@G^+;9fP7CMADH`^( zpf||#|LKkSAmPm2YqI+?2*Dcs3P}7w#I-hQ9P5_N( z383-6%Ze^}B7`r0BhnZu1c84L^Zzr%U#Tf1Tz~Rnt2048XFsy_#urW2saJY)dYsgc zakf=G9|MO#a^^o*m7`61xt`{s-|fs2jpJtp;Pw0PnFJ|}gr$UZgv(+iOe-QZ@v1BC z1`qAdq;cf?r!9A9xQ5w*&$`dk$1P#k{nKDRv6kffW`a~oO3rMIB~OE9EMN6O>=oUa z8h6?FA@Q^KrF~UcVz?>OnVH!+CF|52?sv z;=1DV>tm?!f!KMv&e=sFCHjjfNsHCY>;}OSobdOE`J?XhdY_ZFvKOs-vHLCoVOSCVFg{of*j^xjrLY4IPcL zFmmzq&eqhgba=`&jkLq`_dqe5-5A^<6B$3E7yN;Ewuz%Uk|h>l7Y|h}&x{m53@%uE z4@$Udcvh;_BCI{EIeCrjOV0T!Zu`=Es9f1J7YD|zoirA zcJU5X6zZ8LOyb4?5h?`I!)b!o*voe6?>2}eIVfwQWE!^>J=FogNf5$OQLze<%qU_c8$|{A?Et<}n|w=02(r?n>b4A=vb6mm)0d>+k2b$8WFkjhSuFH3LG3 z>Fk*F^6e4uwB`Hvp~}t7N2^iEL`8I+Y)(bK`~{?EaM^z4JaE?Vyr}N(L^8 zb4(ZqaE$uD5Bls2iTgetd8vwtPj6mrh9%ekJ4i;zf~lM9dx1sMhkwB+{#{C#PhfAxuIl^S#vA_cZ^5+x-C{`$%<_`6 zld6be5rhh_r;w1q^^60}zcw?SR(O3!klLCP=C-Gwk5>d(iTXVc65j`x{Yum9eTAL!a$J?9F^Vj~71ff!mI&D| zr!O6M(A=MWGCfMLS955a$O2ltqK@(>chR}mc(h%o0+mk^+>g-jlKN&>Xf_XPOccTb zyipJxYEDnp<98j#<~glfV*RnZBH%ARkH#K<@5^NtYqz`PM_N|7tXo!P`E(H*TEJC+ zn^#M$(Qh!`wZz)kLQ{_WGwZcS`=l@ok2|8m=7(hx{QhxMg4x{mLMufWn}vzmDpZUx zFMy5Tx?&g3WVi2Nc?U>LPS8-oORvT90UGCsgvgZ0Byoy9OM?yzq|Jn3XK_h(9+*Iu zG7O4t^_sX-qs$s`_O`B+IiV97%qNRspFwqGSHxaB@4h{76{O)qj}N=?TS7Kpa`pGu zKTs(l@Bghq2kSS+p6sU8@9mnBQ48gc5BnEj^Sdp&`ZtnL9G)H?{Ylp4di~WhB<8~Q z2CIT+hNS?#tU|SyJB>xsVUbw4qrNvr>O!`4@lllJ8DE*HK4@@eM1e>oSCyGIztgz-m%I-#eE# z`jJs_zuUCH>gT2Pr3QB_8)&@x`yhK5+T2z+OKaKU02%*>;~hB$lS?`07OPciWJ$=e zl{NoWM(-uNxEnUR+`o?dtqEjg$9tRW;t9L`CN|F954T?06-gqh=gVgY#A-$7XUz!< zA*8oi=-RQ@8?RRy+NSgMwTAX<`k1xw8_Tmv&dO3HdkNU3oZ0iDdokF7?L=Q{{L16J z$sdtSK__N)^a(ZkNj#leah{Y*xC$Mmuqek3jEt*NWU#G!zI>*5jW(gJ@Wed?G*_7> zxi;r^oXs0OpR-;nHSqg*s`RC^W);-UyGh*dIGW@!v#biwz9cbZJVexYD* zW2@)gRcg16dmb~j8xdeTChNIx@Aqd~S<4pMc?H)N(zeDcUxYCwHK)xtLqS?0DKf~f zBbGz`1;nsBu_q&z)hp<5fkGp(1m~5a{q2oz;6!puo6Kg)SeKkGJIHoWA&frh?gL)c z#N0g~NPM+AYJE5xtqlC19V(hkUicCxz3@|$Iyw9l?7Ti!%e+_@Z8A6_;hAl+JSbYP zc#Uof{oaq(!)kFi0DFw1knwwD#OSm8ZY(l9FuI^`CXQFDn^APJj`zkwvDk@vj+D0? zWr=>Kp?87b<%KL^)(OlLHbBmd*m4@VLWXpI=!G`tXyw=IGO8^?d2aM|`iSybYjDrn zOclQ;rt^#-5GSt#{<^K6Ae)Go{6GiD@FW4BeY`UaZT<>M12N7rq7IxoQe za{33(yZ+D`3Z3-W%^)C{lf#(lL3H0}Ho~^X;wUCF$0C2wH8ZjD{6Od{GGpZBAvjpK^~A_$UNOB@P;N z>by{_f+{6T0TsH|Uo&Yon`B8AU!~a3T&8M6?n7Jny#ys&!^Gu*8}0b5A4%>?#j*XF z-hOMM>5;LPOuiBscT6-a>q}MUUG34AdBpVG6Mc>)Od~s3w9=bb70TJHZhE+^QPw#t zS_&%jp3v1xE8M^qJvKs&BHKSl{XR)L^WCTL>Eh#{Pj?@z*0}V31+u_RghMad5gR%3 ztQU&VOzTJfdSbNq?Hb?1+uVs_x)wQb$%2ZnMb9b_T5>^;yZhAo;OY|Tug48lYkpo7 zbeJ&kPkjFRVamsP)D}D_Q53#SOt$%TYiJn)3i6IV_2u|6N8P(uG`?)FQcEChtbosU;Nku<@(~tw;SlO1!mR5Y?qxc}-`DhaTh1tuI@E9rp-Kyh z;tB=Wvdl6e9hI4w{hqfDt#KE#ZqXgpXag^5*81@&6QeMch>QXP4laq!HqOz+@H85^ zVD0vD`+*V&@_qfF^W~a|s@Ed3xsX-z1zqDT*0ox#klO9oeC)d9`9sudN9VvdDB74K z@Arseczx3PY&$pJZByN+(#{$oEa}m&yA>2Ogi~hbJ55_ zmDV$b)|$P|1-)2%+uKYijxiYW-kxy8zx1wl>rh~39qG?Ezh;D7V~_p4x0604vi7yF zKlOzS_+qo*TF4MX`{1M8-zhXagF6M{7%~&_0{6ak-KU|5`ICy7fJPCiL(RDdBAkQ1 zSZ+&4oj`q-aK*%DNoQUW@|3wKOJdi{SZ2v?kmWoMf-NPTklJOf*+aCTr=qKc#7-3c zrO)DYO?cXMQ-m>|YS!HH#j;fFItgQf2#XT-pTqGGe)hM&OY?UfrSjN;@~6f5_w(N$ zS6Fq+IOH!*Xm|35Mc4`2?uL?;v2{eg_pDj(AKbU;_+fzZ)a|75##{_MTfLbF4?`D3 z-H344=xSR9rU>u`X!Q+(qtQZV;{y}fLIl;oi*=!V$CAPz#fFdho{m9bc^0bllMYg- z4H&sgrjci!sLLt`2m_^&T#l;1C?IqT29q}CTBlu>Oe)4xuh$r5EiQ_>4&ROGoO@@g zT)!tL86w}{SgTo%W-_m^Nk0Y#9!XNim78MfwJUt)g7zE`;&E6t8CB{QbOe9uO&_hi zG3lixPezNmr!JM}50t4Ae&hrELxySGIXVO13jTt9tv-xGxIEff1Lti?*ab7r6Fr!x zm%>oLuay7wp^@j1ky6RnY)eZI`+PiO$q4O>l>^@TkHF;;KHbd8E~3vpT0SrWM9A{gu z9DMQ_^o^QExJ6@{m?{jdqgsOO{a*j(%L%g4#Qn07JA? zLKo2j2$1MAI7FoNrda})Rh*<7J&w@`yO0PbqjHF$T9C&uI=CaTJ)6P`c z{iWIHGK=0jRd}T+zh4KC*Jzb{3AKZfisRmccFP_YG?`wMCa(lbQWI{-7-ElsMK(j zI>Cr=-PNEIdax5*BU2GLA= zcV^~CXWM6Y_FN98LpT|8W_~7J0tNs{wMXTY9SJ$1Y4Xd$q0Ie38Aun`P^%dm4VBG2QfAM zN6%TJVc8A?t_6PBN|norgL2o@ZPQi+a;VkW^h+v`I$`D4_h&Bqo#IZy8<0Hc@n#>{ z5B0fkGAY7rC5lS<$s(WZD{SO7lfUi&V%AoBzc>v-8Li{e9rJKEbQv--7K_a9`@fFg2FYxPQ+@Fj4)C7!=tdnO#ydfTcabW1o+Lc;>dK|4 z1vvGW$Db8Nj5xe~s8lpIjR#4xa2lP0K|$~mp<#F_Ej$iki;GK;U-N5i2CK`;?MG4} zpDEsDNh&JqT=N=Nn}PZ|i^E-0LmN9>vDpZHyQKGP4K}8lhO2#+#Ak@=j%UI>@JBny z;a>T5w;M!GUWPYDw8D|iCw1(A{(D)5 zvS9g?&-1tedKx03bi1J^jTgGLo)3*>U=0fuE>ZFZX0v!21Pt1>!Rt#Dz$Ct9{_?_? z8cQ_fvtH?;4Tdl5-LDX1#_)_en_oiMO>1 zpr6p-$|lE%4X&qwnvJtDby3Q(FMqe+=M7(El-XciiE!KW<*d)wdai9$K$sqycjANW zTA)YM8|5v_m)Wlf2)M}tDck&qY!>-2{bleI3aq8WIY;-k9$Tz~79hjj+zy?Grt;d&bU|Y34(XG0IOLaQ&Y9o-9MD-G zQC7DjkCCwOfvs=XBB7|z6vrtPBEv`*OFLLo5%ySX>v*OW?_TG0gVF{}e%q&AbVMmH z`flzycek1t=03TVjKvka*D03OKwY9O5JU^g951bvB%c;qO-(o%-pK#6A(5) zf?nj(#m&)DYbOdyIojnBEEY_4j1xr5Hp>^2z#AnHIu^-}#jUv78&s%uy*@)|GAK^4 z@5NaXo23M|>=qESTODlI%4mUWBui$-|K0Zjk*7n4>bO^ZJnk%*Y$Ud2+b4HpYrQ~< zlItA=tZ{!|1KAHg^zzx=_xu;XKOI{5bXAyrA~Dr8WQ9x737u{=*gsZkHDXZ4z-Qpi zLsf7l8WG$d0YGZ2;=a}B>b`aBMI^PToW0s{MPh=8d=Ot>TK3}C6z^FVPnU&X%bWYG z8`nwwW-gs`*;Wl9{GJn?-dCg^&5Wb3vWB!fnvYgGMB2LAdNO!TdV(EI2IhE8aKDX2 zZQE=a8nr7RDXxu4u_U@^kQM z_;Jto%o82((0hh4|I`cU%#De587!bN6vh9L72W+Zwe?fI%El^W?P5bHByZV1dueGw z{>Jj^R5_mglCr^F>eeB`X?>_;w%yQN6qq~1FqH6gz5K}h#lD*4Vy{>g!m z{Lq$t#>7#xLNgs1m=k1Kmg}0i102EdHPJPR%`34&33J=)WG${;OJnzy3EmJ0+$nA z?>dkCe41^-PP!^7NrsFD|YDpQQsIB7>TjMGjtD0~jEU z{h8;PBq>_0%bVcdaZePh&X7K`Dw^u)rpQcI$quZ3X2Dy%lo1N)pJh$r(tVopcy;@o zX)8OshgTEn(H<}in=H#wlUTW1cYsrFv*~Bcj#Z<(_s}6)`DAI*D5>kyZN=(?tjy~d zeFciaK1TB5j`vrECHB6MXhL)KlL?YW`a#r-8|Li-ZmR~J`sFI}TPs<@iC%n!po+is zY;2ia`>GT(R2_U42Qff!WnIU9Q0*pXC70BGvRq4dd<(564t{#ozSK)?KYQg%#)|2H zfEWVIH9#rT(q(qsABa3RG+9vLFqKd2m>5n#z>iwQAPYnMhBCwQOCpSknu<~|cx{(t zGOyeuH`7|LR?a!ulXsKaiYTTQ#FfX>LKB{=WuoN}Wb|^i>^;wA%+Swm>G*xCLki1f zDJ^4oNN7dND!0x?6B)Ag7ZJ7F7wh*tWT%0y6{o#U;pN{KuDLwLqVOK#Z4QGfAaAR_ zi-Dh|H=SsUzO(h{;}3;C{%iS4E>x$R+&bgwQQahE?0tFs{FR!@T#*a^_%FlYgs9=^ zN16bH|NMCxz5DYg6CuC<&*#E}|83QNaCWjfpq%CZEg}C;R)3eAa|Shx127=KC?Ith zA^(4NMdGU2r9k*M@13F;((^Q>fu|wh^E9p>2eh|Ce(K@- z?8-P2#;z0fGcK}}bm-W|*cZ)YHrhlAUeA8w2)lnb#$K`YqTb`bjYweqH?hpJgXmvX zx*l+JtM=UdbGt0^^L=r;>Y*?V=+b!{q9rPd-ZhtFFUF>XJ?K=0(#e$0?2EOi1aqSqYMR?*(zcDvA zR5kGg#VJj8BCAHkkjo$9`+jH$(~v2_kS4-$sxgbd6Cexsz-eI2%oH|a#>Xxw2ya~^ zD`PxLel&CzHeey&^hHV45#1lrUwG}SLio3UZFSWW&N6Fxk%L3lQ@Z?f$t@0g9OtTS z6D#4c7ZVkm-DK^GEYfSJy#!nG%h$z)}g>V^eNZsVWnry)3*@y z&&e^ehL6qDIsh+6Oc{qm167Jq3ATjL{3$bj`?&bJ-SC4iA1Lv(-l2SbeUZTHZ1o+y zQ@vs+2e53;Gei_re(`b&_4ny7!TQlmk>ZC<4q@cQQHJ-b98LKgHq;Ar_tY{2>~zj| zQtowHP+1IaoPYkE-Cj+JnaC2*;s8TDg(t;dzSqMdAc#Ag%l(4bo+y zk)U_{HdftGNACamtcDhc*z(^FNo~gKHTF}!EjyFt!a(i%3K)r=B91Gd1YG>)%UaEe zL8B2@5E|F(GS=Om^ls#PkZUIfW10PQ~dFKYivy6F)} zrg+i!;Z-T0D`%f4W9)ENLUB2VlL}NoZHnRiPj5I&2xsmcmEVK!L<;xATd!~Bu@`|7 zx{kkJ^8H*Rfcf`-23pX4qe@clq4@Ej^Ug%HEvfp2UhBWJA#v`aVih2YrEU37n_0V~ zPB09K-WA&aq8YZpv1%gPp2|Gp|1NuHG(1-Aev90v+V6bpKZyR6F8hM(@Agsl)QI`f z`?(KWYu+G5rT1WG!dSD*cMO2{`&a%EY(aqjYZ4pmOkCk3ofS#o;NXe6N*Xmq1S7vf zq{tE|yFFtDlI|~yEnd12>{*@jz{6 z-mie7yncUno09YTMUJoM^_9btTz8xdppLgu~^wImBmlbeL_e^%>|>#VEOHtD~v|l;)B+ zqvqRJ$y;VO+viP>FMR(T5n%@DcvP!qS?eGr?<<3KorZI4kM+^Dug`dI{&;3THHVw) z+;b8Q+yPA|`$bL;)}`MvvAuLrkl)cV)%J{!&Ars8GG(0|k4rv$a5ANsP`cL1Erf7# zwP|Rg+y8zv`y!VTW)s5SwUjGlUp$ce;>{nZ>^8&6S|N=+s^*3W=y>&o%1)Ebpn&oS z8UHC+b{H*H7|EP(E;4{Akd|mWH8KS$!jKam62!{Nlci~^u8yXbF5^!(>EvavWiJmk7 z%yrPOt}eR&!IW#6q%H{tg7rMUgC^MvZb=32=_gM?X`s0^(A;?CoTc%DltfRz)3#Q) zIB+{Wf8>@MY3{5@fv&FHqKsc{ zZxIK@k3h{ud@u!ZXU!KyPDg*hElcxycuu8R(Jyb-cR#wU!0@u;IqpzU?&z$m6$#Tg zu$wSI->J|&CCdmd%anps!D(jUXVwt6lli3lJ0U{$9R&bF-@4cFMY>h1iYU2$nEEfa zO5HX1Ea~>-0r6`h`zbZ>T8EB`yyFnBp3vg?HSnNJE`~Q=E$PgQPZ05&2lkUHV2z1q z!p^B`no2kYLtu*NiVl3Cg#dQYauwh9v(Tbs}#37zasE-v8H9 z(*G+ZDfIuJl=S~fNecaclal7X=Si-vbaF03w*HNz6<}N0tpy=->mLsjj_~bD#bas5 z=t6eSiM#Hf{)Gao&ALz^)tE>JQc&r9gNar*2nc7 zu+q@yOdcb(L7!K;{Vwle8l1DU_I*om<^H?_X+>N$^27?CHWWG5y}pxWg5?*$*V24N zbtSkZ7Cd;<1;?T+Th@0L6Ki;Jzw6WndQ#Fj@_sDk>|3u?$|Dz2BvP|M=Hc0pOldK< zTzI<LV(>)I>ODcJeNIy8lw;$N_W6DO$S0A6H zAo~33>3ncrS9JF=<|M#%<7#73qE zzah?=U+66!wu=mr*#1<0_}d>xE%L+$%P7P8BfBI?YW%MTz`q)AlsRwN2rl(2 zo@G_huvi}4KmOgkwR5n}dGoJ@J)*PybGy?>{I~ab&$?KCRIMl(eXfkUltlJ)8*ry~ z1}5IYk(>jCG9)6gLP7hLNvnPGGV~q_mw;_|{qVajBJ z7wZL`2*d8f@(lK%5)`P5J6h!&zOqfcH0Br~x>kMXo|I!?ed57uA;*3BtgOYQ@v3N# zgy)G|l!$aw=lOb!YFrdYXJY!=g#R)oO)ZQ`)%)37Ke8J5CCb{g)v=+R7t`gUdo#-s z1Ibn3dQ?YoC8j;On|u=I`f9XH%pfxXrSU?`iOhPFKo#exwI6{ZN^CXXE zC}wAJwJat7lY4b1Owy;v4}=^QV|O|a_J0OeL`GMrzVcBIn|W?#jW=ab4ij?F z%hG2MFX)UL(yjuM$%d#-XbC(x5q(u~c6KUJJm#s*#3aU?_t%TuEU8cP;|d4sCp`cjbZ67&Piep)dPDL=b$mMaF&X2#2{u3CAEYRP4ZI%21U$SPfCZ!r8ZMKu?n ziDMpkS<~UE6qr^?;amR4*Uv;?zeD10u^u>0q-R926ez^;Or4_TkADw{doKR-p8rmm zFSONhcWe{z{d4$}MVv1xp#2Js3QKLuK)O@OoOz=+$bkO(ctvdY4XJjeOWJMK`1H|O zZck-^c=$9LoOIl!%}tg>?awlxc6CN>s~Xr}w+J z^wsUoaf54P=c$m1jnsZTf?ojXI6LoVx3W=yidQQIwuR&(6H|2&ksf5;}SG2LBW4gKvUym!sBS0fr3 zDf2l3smrZyZS8<^!mP2KYrs7?7bHm0oB>bE4(-B#-g&XM;+|(h_6RX90cn zUQT04F8#x!1@5Dn&&7-NcXO5o8V%2r_Zy%D+!}o4*!4huOddu7$vV&Q;C_k9h0&tM z0V;?-*KN!c7_8GG3D)3jdacN9-~rOc!F!f>IaqBP$w{(6G2bEK>iTDdH;cnXWzI;A zxbp752%6?0l2`CDVjm@K4Wd*cL$Nk8M{<`}N4p4G(ySDEqFt7|>)$;Mm{Uvbx-dC`Bb(-8;$E){B$ zljpqNos>?7WluK|H*ocMUohgCt*ln!@n;XkHr-f_T%Z5_Pc|6OtOqlkQDcQvF7@0H z$@wMV{6|b`0v1*AZnH=EvYkX6+}z_W#LaeVRbw3NuD{x-c;m(r*^;;wQpD4kf?MvC zJN3qHEx5(lc70W{5;lyE9oLrdO)W4DJo=}3kjghg)6MNepLM91CvEmDG&oI6aqO)k|vY^r+eL2ygDNh72hJ+d;iIW4|7qGpP$sd=$%`YQOwjdjIJR^)lm zb$;5g``AFElNd!MB|+akJ$)`+E9#O-i}no~kT3oPFOzG<2C;=<2H zF+24v^5G3rQd@axO=y+*kdyO`$%=>TUdk*{*hhYZDUA%Ff59!V;y=ZHQBM-gLQ+L!Fj%f4+uy+JiHnr)Z&F}&U=Z(fx`FFtxOZ>!1Am?cYJ!Y|dd z-}J8ZW4?NKx4}A3gOgR{RE2ZOXLomIh16lqTT2UUwRTzwT(KN$@9a6J>fy|nE+uMh z2MbH48Q>^R0~xZ~AE?>c4dLm(SE&GkpH(e|!_Z!TVh?x#oRveikzRmGsx$R3D>++; zY8{_Ep0rVP9)8B+{yU9*!Zl6+>qvw#1ZG47L&QLmfPBX_EUEhW;)3JQ>KGYH+$Q*Q zC>fK2bA-~-kzWjASdT?(>bjA>ewHpAsCe)v%qjsV5e`Gy#avKxVx#a^4$D=L0wQZR{7qU@Amo*J3uk+Z;&o-Ex6OegHQl2plxb zed|&s_wqg5c9%H5*TWgA>lGt;wG9oBx{AP?Ev{)>j;k(zfklN$fn?;T%-rsgWf_*Z zBjah^=kFdsYY+E>e!L{a?i|TVNeX~lYLr|Weeyn}oKjQV z^$J_x*Ou6~oizSbP@vDj_2eED(a9aP@|z&!*cs)Om41)0F}1eJ(nW8T)PViAcxP%@ zW|_^Pss#T$i?KiYqOr$zzoM!tre;konn}p{yK-&q$%Sw}S(KF(N;`^$+w(x8wByg$ z@wh37e!B`R5V1UGr7S9ePo1z{l+71l+M0SbaDw`tu8+`#4mPu_DwtPNE?JfCmSOL9 z>nF38VzhGpqAuiS7uD+SPM{4up0$`FOzM;Udam_yUBhxkMY7wd)P8{m>nY$n+$;eK^}YQ3pZ#H*V~0M5*-v zRx~?ZdKPLvEmyokCR>F9InQ@|QO7xP3q0(RfWaJaX&&1|IXqmAV&>C0s`n}YANLXW z=3wVkDkdc#f(zIbR_4Fp$)0~BF4y`1vxTHduA>p23-2^dS20obcksI|C{$ChPG9K0?O;G>HS zecOYkDEl}3{04<*Wsw0d0RS{S)7ac?UR7vQN@Z1@_iJ}>c6-^z%S%eX_%f8({KGCL zHn!_0+Yo|zS9_g}d}JIBW~`EA{4(U|dsY`S)o|1aZlUyIbS9vF;nhvBjXNKdf(Wy& zNdGSG7vJH>2&^SP(zxES-+%pfSo5~v^y^mky5&tyl7JeRWbv^gs+Qk9;td0`DMz zeVQuK43vV?bzHY)RY6yoX--Z%2iN!IG*d5(CDpmS!&AGd$|$28N+;}bn7?G{j+?jL za`@PFdT*vW4^cyXefqtAjQisDv;EQQL0xUxk0;ArOi{naASK=ihGGnF?K7}KtJHhN zFzK&*rJPMzOjW-OHlY*MbTM@gQzRp^QcxzJw62;IbX!v1INFwPd}1GM@_3EwY;W99 z@rByektF`k6wg?r=g$+pXMB!53CVE_yr-Gd2J5#yRv9SR zylKz4CJ0(#?x&YDlIly!ON~2oZD$#EmH)@=(XWL9t(b}YNXcVN^TO`Y0~2cLv!Oaq z5tMyQ@$!;UX`D$iTd0sme^OlI-A;Rz(fHk5yR9ul+jJ zNET74cN4bVufVLVfT^4V}1O=o(5n@{LCPEq;|q>@ z2WSV$OTF?Pjpwm-3HP_@^rad2I_bvRo=-pZN* znWMBh7ShWIP+cv#`6>ic#bj-jLWgvw(;@$Nauo^xRo&WX;p&qZ3+grlzqmD z5@yElS?!^lVE16!W3Tc#U(LzjAQGzsWm+k1jfB%9nPR4eY6DI{tXdikS{N+IV|jyV zsF$ss28Vb-r|*ZK#Jt2rGv~wKwV0`wQt?Fh#}=IzAM-A6Nt4qEJ54Y7Sq^T#J|KpL z7b>VE71&wll9L1jOZ1#GFeb0}oQKh0J9(zUB`r1a=KV|m^(FA1tA}bp9C2GK#k^l9 zla!RffUzI?^=`j@&m>9*V=UP+WfQx?0&LXgO2jf?znYM)!s%X2^{$KvrRmSzdRI!oz|MgLd z*cP+L>yU#zJuKgaKrB!Ku0`j6U-`#Zb$Aw3t4RTCA&@2XfJ0(ay&!$-7U^5^D@6AQ z>E@n=fXZoK!Pka<=rF)O(9-eiT?~>UPzQuhvHzdFxqgYER9WBNZy^f z&YwO4%UVB8*P03dU+e0mMb{H1di_A(M#NapA(eFNOBC%Qgo`~3U^-fH^NB{7hgxA^ z_cp)CMJcHCB^wdjK2D71=8snn=?}I1s)xINH;cbwoo|B>!K|$;OhU?OzVL+NIfjQ?iF=|ia6pfGAgZG;Zska za!CS^Q1YD-1j0j8(Z1n?2qxdJz|O9yqFA^Ozs|{<7K0*HcWy*0AP zLA0>0pV=MT$?^+UpXDM}ZjSu9q^~k-4ohB}pp@FLlv{sJc{~M~b8_08PZJUmnPu~; zU+fr(?xlVK_;vTEPxdj1h3+FYZzmA10k&GoAaNJuOG#Y+RCX6)!EV@N z>pqp#`I;%8T!C$yvvIf`GIcNS7Idv>n*=sV^0EyPMHm9iUbIJwodo8oj6mnJScmc} z=a$RY65nJArueIO`NY2|2->&>eoNjGP440B!o-0vU6JZ%C;MUxuWE=($Y7|HX=h#C zSmTwj8-lNaG+FXEx=jR*R@iuY2gB~}EU>vD)6=zca=u6rjjHG(HP{PuCpTC;*P}}+ zqenU3cTa>#g~q_u)8B8Ej?K)H?O|PNIoQ}f{Y1n5M*rkhRkVVLmUoY$4Yji~5jVQm zZk2M7s>5rUyKS`*{keOxTZfm09~C{hLoP!9%LObY4W~~*OA-G32@fG7z$Q{vV_AdA z70X%KxP^}L7i?`i3DO;|@gvYlYpl>RdjgMPj5 zRS#~}SSPz>{^n`uJz{n2JHHUM?BKRn!L2(h(ol#4nebKE+$TW4{vyoL##VmxPqzJL z_wvkDBhOFS^e}Vg?-+`#Mqgt|W6{A2{h?zz(AtYBqIROO1@;~3hh4By z;h!d447Or=hD^b&J-t9ltP+BZGYR3kdz8H$bLKW(opr=2q)&ekJvR8q*z`}LNLlS{g@(wlwf2KBXM*2} z7@?!3Pe{+&6$*TL2PqDQjlu3DgCXLw(D@6J4~q}_6#=vBM2MpHSal23^`?P+M13_~ zFVRcpqIUct|B)zv<1Ga(QXr`4adRC!gAf8th=)TLs2tIB`@`~8z?#sPar>`5&6!ud zU_q>1uVr8}^5ldl~ zQ~-d?eGzGJFV24`Xv5?zP#B7sH-J7qeoK%$-Nq?-Tm*Pom)&DA9r+=CaQWeHSX)Yq z*~h~a3ADdVS*D&pO_{$a=t%>RD0D65^^%Pz7FkaRqodF%e>5b{$~=Q3PInGn2#~sk zMed^7R>^M=x3O_1L;wu3`J8vBM^6@y7b9)w4)s$LfTXd^k zaI0@ML6Zbc_1Fsbm<#usm$3gaszIHqX~yp)}&4Q229%6eIh05Ic133?rw}@b+09Vx1(_H&AEMd(5xpW zrQe;azcWO9rlZWs%jM{HE-g3Wn!#x;ykg^(GPNrx06Emk4Y+`Ryv6}NY_5BLJAZT$ zf!)w`4h#$flQ1xFG~g2O#i%>d*J6Y!Gi)!X3m5F*r+4NoGqPT^58TMf%WliDfT@oV zZXX|?zwj5y$3^0r7YrI)ZjTqZ*fn(LJNVYQVWSLw2V3x`Pv0VLP$4Vn6#(;nGym_O zKeB108)Fg4MIb8{$8Lnz8<6&vGfUgc+}KF@n5-e&2zvGq5CHK@tPyU+wru{ArW2>+B3EIn0v0kFk%)C2 zMl?~-F_0Sk=k=N5d1&`YTU|R_d-E~rP*8SGb{iK+U7H~~3Q7(Y%N*LSDccDAS~|_k+L)w_b8>`6WnuY^ij-ESi>C{l z3NVTk^rBn0WA5Y2=|3MO^}ErF)y5Wz93pFqdN*QY=9q#Yk~aIYu0^ktya?a~k)H8M z7#y1fd;=sCzI6Pt=V+tQY%U*(UZ#8JUYf)CTJa~CPlYJTWafn%MSZ z*K^okUkaImNRT@U9yLT3*{str^;v>pld`&S-{*3AMmlJO44LpRGSBjU>3^eTZ_|Kk zmLXY>!Qpp4#UFDAsLn_j!LZ51rxU{jJ^m6{Jf}tOf5&d}ezb!oqlSL#ZW9Fna#u;`<4CZ~=b&iKX2jJ*|8JPbr4nZtUn75CC zjTZFb1F$LS{<-DwuaSqo|Bxu?kCh1w%b>>@pwlfJ&6>t3oa5*sVi@j^7P(JGl+N*) zkj@tZ|6y~PGq+j2H>Pqfk=<^qzY9}eKHbN<_?~Q3&rOGYtMgpTwMh%mMQCekX0ERf zBubv)KaQiYR5-Le<0Yq3&Ss*ZDXW9cX@dq^jfQ#+CpF3KcX*PUM8=F!t5m(D-8Dbj<=p1L&YS6^`lj-B*k9_kg={TAhob%%FhADeYJ z;r82^@3%KeU@#C0AMQw1kAW%^j%WGPqr6!@nZ6JvG{j(M2vumv|JbRA!miYa5GwRjP zr$R0>%5WoBpP|4(P$x*jZ(m(kH&;_jD>^YT(`#eu_@K;jlAnTBP~B;&ay(JU;VHwK zRot0A#>gG_5(|Nl323Ytk}`QselP!E7E%i)q0p9-duU~211f+@eRfO|b{ZC4{z5qC zxjL$$m8-mMV_8$Bku@M%**}md;a3GEv9os@)Oi%BK5@;b6&V=%_L9|ZBv%EI-5tfc z)^xHt0;B^-RJpnZgwosnlLVVo4KFVnU|oVjgrLqIRA|+E8y~=>U;ZrMcu%*&S_Rj7 zRqEZ7@lc++7jdS6lP|Gp;@%~`xK$e0KwbYnaiepll}<*C{jZ_>@_XPZm0peG9f^0B zFYt?VO!3`$6ma;{4Z`j_VLfm$@It~#LCcQ}@siVNkw2$OY72`tqF=JEjGZ^l&06n+ z6ghiGHfbwwcRTXsF+6p@-|nIUu)+$%0SDiU^&1SP8O?lKvHf@6`F2Qot_-7kEjXl^a+gB+gvR+tmauLn|ZY9t5Rck3WPk5!gwrw z90^`p&Q(d;%iA5)I^dbCGwZtpJO>7^L+2$e$8Zgqz@`CF5XZND*q7c6HfqwffHuRF z7$ZXfms{_(_$yp|a|i_r!Ts~k(_7TkYsv0%g|Z>xQ#GzpO{Y7Yo*Prott&(=4K*|( z2SjbM>%2A!ov>AFDc&;xqpaB5?gaeh6NZS?z+?9QBoQ^kYG**PTBxLmK{|-NE$G}3 zh`LrToUR}_dD?d+Cysdk2%JGn;rVn*8~5w>H>dM3Al|Q_px}6?xAgSn_3su^AqaoL zX|_pL!gud4pCT$2<j&60%HE>C-mr~pEw?6`nv6eJ@nLT5!t=ANBg z{j~|IFY3UJ7Y7Fgpn*FX;q>Bmo#814s8winyf{fpDj0R^)~%H<0&@9=)tf!tlf}FI_3G$~Wi3BV=CK#U`x><4r(Ynne@e~@o8n0i0Lb7!HOpmtDT zP%2m*iMlT-t0ccx(mtXofe?E?Ll{?anAwlVBCu=F8U`T2-e*b zK#Z)6T0u?P8`x?Lh7)f0bFN)%^KjluI7zljl#bGPe_3g)*1bx%7;nPc^hC{l8G4d; z%hdmV0Ta?#*Pj;{*D9=Un>(wx0(aw9b=2kvD zJnVsfPTgxO_`%Vz-0~A0Wyog3E)S^biE_f~VvN00X8dg%QHRfB zU?fpU>f*-i&SLLagO9*@GtiAaj=}_9s-{NIx!O?G<`bZCfF@fq-HJ72VGyQY)jQc+ z$&v{sSt-a%MQD4?C8ed6N}a9zqj%N^Tg`y*?uo|-TfAV4i_Veqy$i z$LII}YChi>>{8-w+*6k$aWQOoT}EhV?J@(Qp$-^TAnB2{(&nYv<$e)0_0Zegpdgh; z%O&bxfUvka)tsXg$MG;L@_e=<>@J9By}FpUs~#u@Y=qH?hN+m-tjzwJOxUZj+!0hvqoKpcM<_miJYwx;E{+mbCheKp>(KiG^s3jxK^+PZs7Q7PxrEvq8<@e(MpwPP(k#(uSvi zu_%GkprbXE{ZwUvqj1uI*okAY;epl=J^(*yvpU0!J$G4)<>pW7>44Ih2g?09*x4h& zo->L?<#a8KUS#vzihAc%mW>*21q(e;S@X{uK=Lhu~`?+la0;VgQnH9P7q2!q1!hjdenSgJRGGaS)hZ|wL8b3 zA$pop>)e+qA(Z2!rY;iIBbG7cwKbsO?7aCh;>VG{B*k4}t{?bfU1YMV%@Ce~Q4-Wq ztw;7XA1z(3`|)-cSp1_+0e+2p!@c~p=lPQ?EK?69brljHz053W*tr5U8;IG)F&aZ> zH9?)EutnVxqk>Mxw1OB;`0zan*rLgf2Bg&xRetg1P>3>O*4DYhBO^=-opd7xqN1X1wMIH5G8t1-D2KfLjfk<;F{A~$JqYZGtOxv+!TiwO znrVD!z&cu{X!U#um>?_R;<M#bG#T#xso6nn!;i*VF30^dAySM!+V#Gy&79A4>C^l#zFa#0msiORr4)kdOoKU z)a+=1)?UBJtnEw#{HY`N{u!})wIjApC(hXI(`L;sk4pVKrIny_k!ojE923fRQq#|C zi!wekW6Ge~u2|R!nzBF2!2C6K5!_)C)P;td1gK13ZRBT5R`2A9xg`Tu$}G}cHO#?m z>G-_blj|OaG&EtJKZe?Iw)?kw<||T150G$&QwkAVy!aLeCnskn4y@j-pqO5rh|5go zyLT5;+y`FdOmmvo0 zo#}JWqDAK>pSLEje_k?nZ6~niWaZ4Tufk4KMTDmKZPo%;xJbb4=|j2XjDd9s2!Ys( zfKFS}BCkrFe%;yGd0Lg?HTBA;(L`czC>Vcg)Z|~=gg;*UcnrKhihyy$CL)44kBsl> zv#vPCt>sk=N-BlX=5TUz>j3+0{PQi&OtEn{ph(8uLD_mRN0vVB2qfZlZg#uC?lo1p z%o6nc&xdksLfk6npf)Z9+5sxlPi}Zg?EN5i6HDnC9K;ByN#(pfJsonARg1u>M$_fu$lmQvyM+WB z`>~!F4SI)KhtBY^wbI5yGTyou>;(lf)k2oP)E5^QSHdumNfeOypj0WQ9u&$g4tVdj zwo&cychDPJzYWvchx7n5XJTpZe%boL?)Q(D)#02_La%}2Hf{~0hKNp1E;`z!w%wQl z{(A*ks**>RU7~bTHje@GE6*arsdTa;O$RI}t$VqBIdORXOPAiS zLNkp?CIPr+qPPTLP4f!~fYs4!W2R9aTfH?DL_{p;ZvC5=>;sUg><9A5c60PB{`M(n z80?-xUb5Tg50nxbKnm)EsS+^=L?#h2$qSC&L4rvmIGmJ?3S$r-$1zRW}lBf$_=&HeiRek8QsmgP_i+59H$s4^`oAnh4(% z_5Jp>>};m~fS=EcO{-OjPjDkXS%W%YAz4>YxQ_)(6O;;VGY2&LqCSw*rQ-v99cGkx zks4WY*Ku}zN6X0k{1>esDAA|Yvy?S`4RNNra&i`DTPKWr^T%np>8lQ-^=dBtKKqmT z;V7eK{Fzmmo(FCW^KRV&XjR;ApkVg2&e4}8{A>p*23LW~XO>+!xzY~omSf_p`L6RF ziI9J9Te>MZDGbJDGg0Z8RI2dqQuDDjc$&wHacM$+`*eWh)&mceR(xM9#jS@OtT5)I z*c4%>L^n4#L7Rc(JNvh#xz3uP=0t1j9V(k1PN+PhCr{Z$fnytxgll-t|ef#ygkshNYWgfT)|qd%b^e-q(W z$F#_(c{Hn?r_H}xz{;Mw?sP2xi*DSI)u9`~?Dm+>ex~7lNz*ZhO8ld3Ft5eyD`L3~ zOE#zLF?Dz%Hd*lbn>TO%0+uV_{QNV`^R!fw1kq_c4KJYD{i*z2b`cSH9;LX#u1SDS zinvW@O(zU8{{+AS?XmRGC>SjdfCR456AlvKpsu4lBl@3WKCrRp|HJkCpR5xBOMt=t zZ-Potk0ki#xAWoxPmnN;fC`IQLib_X|NGKGvQLd$mR5yf7ysZWT`yn+d__l_j)~vw zVo(=AbY3k0?1O>=Kp_ApAu`L4av%wX{U!x~t$DEc5Rste|9~m)(YBAe=5ZbMjIdXu zuNzEgB7P`+T3ovmCg<}cHGZE02H6WlbUw@~Tc6sGTwwJkA_7Q~H0-ZF5bd+*RX;y{ z753O3EUtaly)Snk&@kzmbeIbxM;AZa&4Q9toSwCod`VZ6s6hf^mE8D!5H|O=XHq^A zR$suWtpy{?^daHY&2LZ=51->FH&~ZuImHmj2{1V9%5F%@ngM1DK&cu$tP=no{(cI|kz_v$cwe{UYsbz$RS!YRCx5|E=$W$< zi`2AaH)6<^Et~+nH3`s?lQ6R|v7xQAq(Mr#-u^*&W`AzmyLUmv|ELGInf0==Fm+j@ zG}A$nzK!W-)62RJeY*mm#My`hVciM}8jGwj!J8j4AB3nO0PXrYMRoDkO*VEu4eJVh z@Gn=^)be6bUB^7GXTz%(AhJV0)eRICW8Oe7zY)DW@=qJ2{?<5m`WZwaA|8acKR~uW zpknj~M5)gchL}1dH;^Pz+@sS+WtkN=D*tmwz^FvC;o{J>!_X+rNf;XNPWd%@0K##r zpWY#%(F5F@jo=-yAFx6h#6-uxo{*=_{yVs3Cu-@{$QwM*pc*EF%C{6_C@W6_!GypT z>0iK_4t2`K<(5CH!%^KyRr&2QV4qsdzD`;q#_Vy;St7&cmNj;;{L;dktejUw=`1}<^R(*sPKPj8#A6MBxZ3WW|wQIL2nvI zr63M#yYhz+SUD})#KY1h`Qpy6|KjjWM~>eBgyyK^PszS(w#P4K)~MDg)6GT}L4#0# zz(3I`!aZU#Y9?01hau#6B*=YTcCGs!Pa^dfQnAaF-sZ{x>Q^FkU;y1UxVjF8HI=Mu znLuV|-5;(dbZOPI#*;I2>7w{|f7nM5^9TH@)=sk@0W5Tfnl-13B<#&2vx~1PpS$Dg z#CD&468!(UVbP+rM+&ZBoO-AEz|!>FZnb!wl3Wp()&ySit+{;Z&%@& zj5fHMF(K5?!=8gMpU4c7i`Wvq|04Pu{fa5L-WEe~z&!P0AKdOg0s}RIgO2+kqZAo) ztBagLb!@tmfOoRBCCHs}@)E4&Xe)Vvj~0g0SE2lifc^gIcgDTTu*l1YdLqQnVK|v5 zB<~2!QxJjvQU7`|XqD__Kw;*9IK2v49Q=pu`J+Sa9YDK`Z=y+EFWmp~AD#^``!HBO zr21uh6ZC-a6ZC - > - )[componentId]; -} +import S from "./DataComplexityCards.module.css"; + +type RatingColorKey = DataComplexityRating | "default"; -const CATALOG_IDS: DataComplexityCatalogId[] = [ - "library", - "universe", - "metabot", -]; +const RATING_BADGE_BACKGROUND_COLORS = { + low: "background-success-secondary", + medium: "background-warning-secondary", + high: "background-error", + default: "background-tertiary", +} satisfies Record; -const COMPONENT_GROUPS: { - groupId: DataComplexityGroupId; - componentIds: DataComplexityComponentId[]; -}[] = [ - { - groupId: "size", - componentIds: ["entity_count", "field_count"], - }, - { - groupId: "ambiguity", - componentIds: ["name_collisions", "synonym_pairs", "repeated_measures"], - }, -]; +const RATING_BADGE_TEXT_COLORS = { + low: "success-secondary", + medium: "text-primary", + high: "error", + default: "text-secondary", +} satisfies Record; + +const RATING_TEXT_COLORS = { + low: "success", + medium: "warning", + high: "error", + default: "text-secondary", +} satisfies Record; function DataComplexityCardSkeleton() { return ( @@ -90,10 +88,10 @@ function DataComplexityCard({ catalog: DataComplexityCatalog; }) { const [isModalOpen, { close, open }] = useDisclosure(); - const { title, subtitle } = match(catalogId) + const { subtitle, title } = match(catalogId) .with("library", () => ({ title: t`Curated semantic layer`, - subtitle: t`Models and metrics from the curated Library subset.`, + subtitle: t`Metrics and published tables within your Library.`, })) .with("universe", () => ({ title: t`Full semantic layer`, @@ -110,36 +108,43 @@ function DataComplexityCard({ {title} - + {subtitle} - {match(catalog.score) - .with(P.number, (score) => ( - - - {formatNumber(score, { maximumFractionDigits: 0 })} - - {t`Lower is better`} - - )) - .otherwise(() => ( - - {t`Score unavailable`} - {t`Open for component details.`} - - ))} + {catalog.score !== null ? ( + + + {formatNumber(catalog.score, { maximumFractionDigits: 0 })} + + {catalog.rating_label} + + ) : ( + + {t`Score unavailable`} + {t`Open for component details.`} + + )} - - - - {subtitle} - - - - - - + + + {title} + + + {subtitle} + + + } + > + ); @@ -153,55 +158,64 @@ function DataComplexityBreakdown({ const hasError = catalog.score == null; return ( - + {hasError && ( }> {t`Some component scores could not be computed.`} )} - {COMPONENT_GROUPS.map(({ groupId, componentIds }) => { - const { title, description } = match(groupId) - .with("size", () => ({ - title: t`Size`, - description: t`How much surface area this layer exposes.`, - })) - .with("ambiguity", () => ({ - title: t`Ambiguity`, - description: t`Signals that similar or repeated names could make answers harder to trust.`, - })) + {DATA_COMPLEXITY_GROUP_IDS.map((groupId) => { + const title = match(groupId) + .with("size", () => t`Size of this layer`) + .with("ambiguity", () => t`Areas of ambiguity`) .exhaustive(); const group = catalog.components[groupId]; return ( - - {title} - - {description} - + + + + {title} + + + - - {componentIds.map((componentId) => { - const component = getSubScore(group, componentId); - if (!component) { - return null; - } - return ( + } + classNames={{ + chevron: S.accordionChevron, + content: S.accordionContent, + control: S.accordionControl, + item: S.accordionItem, + label: S.accordionLabel, + root: S.accordionRoot, + }} + > + {getGroupComponentEntries(catalog, groupId).map( + ([componentId, component]) => ( - ); - })} - - + ), + )} + + ); })} ); } +const getGroupComponentEntries = ( + catalog: DataComplexityCatalog, + groupId: G, +) => { + return getObjectEntries(catalog.components[groupId].components); +}; + function DataComplexityComponentItem({ componentId, component, @@ -210,112 +224,95 @@ function DataComplexityComponentItem({ component: DataComplexitySubScore; }) { const measurement = "measurement" in component ? component.measurement : null; - - const { title, description, count } = match(componentId) + const { count, description } = match(componentId) .with("entity_count", () => ({ - title: t`Entity count`, - description: t`How many tables, models, and metrics are included in this layer.`, count: - measurement !== null && - ngettext( - msgid`${measurement} entity`, - `${measurement} entities`, - measurement, - ), + measurement === null + ? t`Entities` + : ngettext( + msgid`${measurement} entity`, + `${measurement} entities`, + measurement, + ), + description: t`How many tables, models, and metrics are included in this layer.`, })) .with("name_collisions", () => ({ - title: t`Name collisions`, - description: t`Exact duplicate names after normalization, which can make entities harder to distinguish.`, count: - measurement !== null && - ngettext( - msgid`${measurement} collision`, - `${measurement} collisions`, - measurement, - ), + measurement === null + ? t`Duplicate names` + : ngettext( + msgid`${measurement} duplicate name`, + `${measurement} duplicate names`, + measurement, + ), + description: t`Exact duplicate names after normalization, which can make entities harder to distinguish.`, })) .with("synonym_pairs", () => ({ - title: t`Synonym pairs`, - description: t`Pairs of entity names that are semantically similar enough to be treated as possible synonyms.`, count: - measurement !== null && - ngettext( - msgid`${measurement} similar pair`, - `${measurement} similar pairs`, - measurement, - ), + measurement === null + ? t`Semantically similar pairs` + : ngettext( + msgid`${measurement} semantically similar pair`, + `${measurement} semantically similar pairs`, + measurement, + ), + description: t`Pairs of entity names that are semantically similar enough to be treated as possible synonyms.`, })) .with("field_count", () => ({ - title: t`Field count`, - description: t`Active physical-table fields exposed through this layer.`, count: - measurement !== null && - ngettext( - msgid`${measurement} field`, - `${measurement} fields`, - measurement, - ), + measurement === null + ? t`Fields` + : ngettext( + msgid`${measurement} field`, + `${measurement} fields`, + measurement, + ), + description: t`Active physical-table fields exposed through this layer.`, })) .with("repeated_measures", () => ({ - title: t`Repeated measures`, - description: t`Duplicate measure names across included tables.`, count: - measurement !== null && - ngettext( - msgid`${measurement} repeated name`, - `${measurement} repeated names`, - measurement, - ), + measurement === null + ? t`Duplicate measure names` + : ngettext( + msgid`${measurement} duplicate measure name`, + `${measurement} duplicate measure names`, + measurement, + ), + description: t`Duplicate measure names across included tables.`, })) .exhaustive(); return ( - - - - - {title} - - {description} + + + + + {count} + + + + + + + + + + {description} + + {match(component) + .with({ error: P.nonNullable }, ({ error }) => ( + + {error} - {"error" in component && ( - - {component.error} - - )} - - - - {count !== false && ( - - - {count} + )) + .with({ rating_label: P.nonNullable }, ({ rating_label }) => ( + + {rating_label} - - )} - - + )) + .otherwise(() => null)} + + ); } @@ -337,7 +334,7 @@ export function DataComplexityCards() { {match({ isLoading, queryError, data }) .with({ isLoading: true }, () => - CATALOG_IDS.map((catalogId) => ( + DATA_COMPLEXITY_CATALOG_IDS.map((catalogId) => ( )), ) @@ -362,7 +359,7 @@ export function DataComplexityCards() { ), ) .with({ data: P.nonNullable }, ({ data }) => - CATALOG_IDS.map((key) => ( + DATA_COMPLEXITY_CATALOG_IDS.map((key) => ( )), ) @@ -370,3 +367,45 @@ export function DataComplexityCards() { ); } + +function ScoreDisplayInline({ + withTitle, + score, + ...rest +}: { + withTitle?: boolean; + score: ScoreAndRating | ScoreAndRatingError | DataComplexityFailure; +} & MantineStyleProps) { + return match(score) + .with({ score: P.nullish }, { error: P.nonNullable }, () => ( + + {withTitle ? t`Complexity score unavailable` : t`Unavailable`} + + )) + .with({ score: P.nonNullable }, ({ score, rating }) => { + const ratingColorKey = rating ?? "default"; + + return ( + + {withTitle && ( + {t`Complexity score`} + )} + + {formatNumber(score, { maximumFractionDigits: 0 })} + + + ); + }) + .exhaustive(); +} diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/DataComplexityCards.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/DataComplexityCards.unit.spec.tsx index f982f78cd775..284289b81287 100644 --- a/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/DataComplexityCards.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/DataComplexityCards.unit.spec.tsx @@ -6,10 +6,19 @@ import { renderWithProviders, screen, within } from "__support__/ui"; import type { TokenFeatures } from "metabase-types/api"; import { createMockTokenFeatures } from "metabase-types/api/mocks"; -import type { DataComplexityScoresResponse } from "../../types"; +import type { + DataComplexityRating, + DataComplexityScoresResponse, +} from "../../types"; import { DataComplexityCards } from "./DataComplexityCards"; +const mockScore = ( + score: number, + rating: DataComplexityRating = "low", + rating_label = "Low complexity", +) => ({ rating, rating_label, score }); + /* eslint-disable testing-library/no-node-access -- Snapshot cleanup is applied to a cloned DOM tree. */ const cleanForSnapshot = (element: Element) => { const clone = element.cloneNode(true) as Element; @@ -18,6 +27,7 @@ const cleanForSnapshot = (element: Element) => { node.removeAttribute("class"); node.removeAttribute("style"); node.removeAttribute("id"); + node.removeAttribute("aria-controls"); node.removeAttribute("aria-describedby"); node.removeAttribute("aria-labelledby"); }); @@ -26,63 +36,70 @@ const cleanForSnapshot = (element: Element) => { }; /* eslint-enable testing-library/no-node-access */ +const mockComponentScore = ( + measurement: number, + score: number, + rating: DataComplexityRating = "low", + rating_label = "Low complexity", +) => ({ measurement, rating, rating_label, score }); + const mockScores: DataComplexityScoresResponse = { library: { - score: 18, + ...mockScore(18), components: { size: { - score: 18, + ...mockScore(18), components: { - entity_count: { measurement: 1, score: 10 }, - field_count: { measurement: 8, score: 8 }, + entity_count: mockComponentScore(1, 10), + field_count: mockComponentScore(8, 8), }, }, ambiguity: { - score: 0, + ...mockScore(0), components: { - name_collisions: { measurement: 0, score: 0 }, - synonym_pairs: { measurement: 0, score: 0 }, - repeated_measures: { measurement: 0, score: 0 }, + name_collisions: mockComponentScore(0, 0), + synonym_pairs: mockComponentScore(0, 0), + repeated_measures: mockComponentScore(0, 0), }, }, }, }, universe: { - score: 54, + ...mockScore(54), components: { size: { - score: 44, + ...mockScore(44), components: { - entity_count: { measurement: 2, score: 20 }, - field_count: { measurement: 24, score: 24 }, + entity_count: mockComponentScore(2, 20), + field_count: mockComponentScore(24, 24), }, }, ambiguity: { - score: 10, + ...mockScore(10), components: { - name_collisions: { measurement: 0, score: 0 }, - synonym_pairs: { measurement: 0, score: 0 }, - repeated_measures: { measurement: 5, score: 10 }, + name_collisions: mockComponentScore(0, 0), + synonym_pairs: mockComponentScore(0, 0), + repeated_measures: mockComponentScore(5, 10), }, }, }, }, metabot: { - score: 30, + ...mockScore(30), components: { size: { - score: 30, + ...mockScore(30), components: { - entity_count: { measurement: 1, score: 10 }, - field_count: { measurement: 20, score: 20 }, + entity_count: mockComponentScore(1, 10), + field_count: mockComponentScore(20, 20), }, }, ambiguity: { - score: 0, + ...mockScore(0), components: { - name_collisions: { measurement: 0, score: 0 }, - synonym_pairs: { measurement: 0, score: 0 }, - repeated_measures: { measurement: 0, score: 0 }, + name_collisions: mockComponentScore(0, 0), + synonym_pairs: mockComponentScore(0, 0), + repeated_measures: mockComponentScore(0, 0), }, }, }, @@ -98,10 +115,14 @@ const mockScoresWithError: DataComplexityScoresResponse = { ...mockScores, metabot: { score: null, + rating: null, + rating_label: null, components: { ...mockScores.metabot.components, ambiguity: { score: null, + rating: null, + rating_label: null, components: { ...mockScores.metabot.components.ambiguity.components, synonym_pairs: { diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/DataComplexitySection.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/DataComplexitySection.unit.spec.tsx index 5854bf2c7bf0..8f756537257e 100644 --- a/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/DataComplexitySection.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/DataComplexitySection.unit.spec.tsx @@ -5,67 +5,83 @@ import { renderWithProviders, screen } from "__support__/ui"; import type { TokenFeatures } from "metabase-types/api"; import { createMockTokenFeatures } from "metabase-types/api/mocks"; -import type { DataComplexityScoresResponse } from "../../types"; +import type { + DataComplexityRating, + DataComplexityScoresResponse, +} from "../../types"; import { DataComplexitySection } from "./ConversationStatsPage"; +const mockScore = ( + score: number, + rating: DataComplexityRating = "low", + rating_label = "Low complexity", +) => ({ rating, rating_label, score }); + +const mockComponentScore = ( + measurement: number, + score: number, + rating: DataComplexityRating = "low", + rating_label = "Low complexity", +) => ({ measurement, rating, rating_label, score }); + const mockScores: DataComplexityScoresResponse = { library: { - score: 18, + ...mockScore(18), components: { size: { - score: 18, + ...mockScore(18), components: { - entity_count: { measurement: 1, score: 10 }, - field_count: { measurement: 8, score: 8 }, + entity_count: mockComponentScore(1, 10), + field_count: mockComponentScore(8, 8), }, }, ambiguity: { - score: 0, + ...mockScore(0), components: { - name_collisions: { measurement: 0, score: 0 }, - synonym_pairs: { measurement: 0, score: 0 }, - repeated_measures: { measurement: 0, score: 0 }, + name_collisions: mockComponentScore(0, 0), + synonym_pairs: mockComponentScore(0, 0), + repeated_measures: mockComponentScore(0, 0), }, }, }, }, universe: { - score: 54, + ...mockScore(54), components: { size: { - score: 44, + ...mockScore(44), components: { - entity_count: { measurement: 2, score: 20 }, - field_count: { measurement: 24, score: 24 }, + entity_count: mockComponentScore(2, 20), + field_count: mockComponentScore(24, 24), }, }, ambiguity: { - score: 10, + ...mockScore(10), components: { - name_collisions: { measurement: 0, score: 0 }, - synonym_pairs: { measurement: 0, score: 0 }, - repeated_measures: { measurement: 5, score: 10 }, + name_collisions: mockComponentScore(0, 0), + synonym_pairs: mockComponentScore(0, 0), + repeated_measures: mockComponentScore(5, 10), }, }, }, }, metabot: { - score: 30, + ...mockScore(30), components: { size: { - score: 30, + ...mockScore(30), components: { - entity_count: { measurement: 1, score: 10 }, - field_count: { measurement: 20, score: 20 }, + entity_count: mockComponentScore(1, 10), + field_count: mockComponentScore(20, 20), }, }, ambiguity: { - score: 0, + ...mockScore(0), components: { - name_collisions: { measurement: 0, score: 0 }, - synonym_pairs: { measurement: 0, score: 0 }, - repeated_measures: { measurement: 0, score: 0 }, + name_collisions: mockComponentScore(0, 0), + synonym_pairs: mockComponentScore(0, 0), + repeated_measures: mockComponentScore(0, 0), }, }, }, diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/__snapshots__/DataComplexityCards.unit.spec.tsx.snap b/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/__snapshots__/DataComplexityCards.unit.spec.tsx.snap index dc73ee92aff2..24d6935fc800 100644 --- a/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/__snapshots__/DataComplexityCards.unit.spec.tsx.snap +++ b/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationStatsPage/__snapshots__/DataComplexityCards.unit.spec.tsx.snap @@ -17,17 +17,17 @@ exports[`DataComplexityCards renders the data complexity cards 1`] = ` Curated semantic layer
- Models and metrics from the curated Library subset. + Metrics and published tables within your Library.
- Lower is better + Low complexity
@@ -58,10 +58,10 @@ exports[`DataComplexityCards renders the data complexity cards 1`] = ` Full semantic layer
- Lower is better + Low complexity
@@ -99,10 +99,10 @@ exports[`DataComplexityCards renders the data complexity cards 1`] = ` Metabot-visible layer
- Lower is better + Low complexity
@@ -139,7 +139,20 @@ exports[`DataComplexityCards renders the data complexity cards 2`] = ` >

- Curated semantic layer +
+
+ Curated semantic layer +
+
+ Metrics and published tables within your Library. +
+

+ + +
+
- -
-
-
+
- Field count -
-
- Active physical-table fields exposed through this layer. + 8
-
-
- 8 fields -
+ + +
+
+
- Ambiguity + Areas of ambiguity
-
- Signals that similar or repeated names could make answers harder to trust. +
+
+ Complexity score +
+
+ 0 +
+
+
-
-
+ + +
+
+
-
-
-
-
+
- Synonym pairs -
-
- Pairs of entity names that are semantically similar enough to be treated as possible synonyms. + 0
+ + + +
+
+
-
-
-
-
+
- Repeated measures -
-
- Duplicate measure names across included tables. + 0
-
-
- 0 repeated names -
+ + +
-
- -
diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/types.ts b/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/types.ts index 959a617cd92d..e48c04326a55 100644 --- a/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/types.ts +++ b/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/types.ts @@ -105,8 +105,18 @@ export type ConversationDetail = { feedback: ConversationFeedback[]; }; -export type DataComplexityCatalogId = "library" | "universe" | "metabot"; -export type DataComplexityGroupId = "size" | "ambiguity"; +export const DATA_COMPLEXITY_CATALOG_IDS = [ + "library", + "universe", + "metabot", +] as const; + +export const DATA_COMPLEXITY_GROUP_IDS = ["size", "ambiguity"] as const; + +export type DataComplexityRating = "low" | "medium" | "high"; +export type DataComplexityCatalogId = + (typeof DATA_COMPLEXITY_CATALOG_IDS)[number]; +export type DataComplexityGroupId = (typeof DATA_COMPLEXITY_GROUP_IDS)[number]; export type DataComplexitySizeComponentId = "entity_count" | "field_count"; export type DataComplexityAmbiguityComponentId = @@ -116,33 +126,36 @@ export type DataComplexityAmbiguityComponentId = export type DataComplexityComponentId = | DataComplexitySizeComponentId | DataComplexityAmbiguityComponentId; +type DataComplexityGroupComponents = { + size: DataComplexitySizeComponentId; + ambiguity: DataComplexityAmbiguityComponentId; +}; -export type DataComplexitySubScore = - | { error: string } - | { measurement: number; score: number }; - -export type DataComplexitySizeGroup = { - score: number | null; - components: Record; +export type DataComplexityFailure = { error: string }; +export type ScoreAndRating = { + score: number; + rating: DataComplexityRating | null; + rating_label: string | null; }; -export type DataComplexityAmbiguityGroup = { - score: number | null; - components: Record< - DataComplexityAmbiguityComponentId, - DataComplexitySubScore - >; +export type ScoreAndRatingError = { + [K in keyof ScoreAndRating]: null; }; -export type DataComplexityGroup = - | DataComplexitySizeGroup - | DataComplexityAmbiguityGroup; +export type DataComplexityLeaf = { + measurement: number; +} & ScoreAndRating; + +export type DataComplexitySubScore = DataComplexityFailure | DataComplexityLeaf; -export type DataComplexityCatalog = { - score: number | null; +export type DataComplexityCatalog = (ScoreAndRating | ScoreAndRatingError) & { components: { - size: DataComplexitySizeGroup; - ambiguity: DataComplexityAmbiguityGroup; + [G in DataComplexityGroupId]: (ScoreAndRating | ScoreAndRatingError) & { + components: Record< + DataComplexityGroupComponents[G], + DataComplexitySubScore + >; + }; }; }; diff --git a/frontend/src/metabase/ui/colors/constants/themes/dark.ts b/frontend/src/metabase/ui/colors/constants/themes/dark.ts index 5ed585866f34..1390d231bf60 100644 --- a/frontend/src/metabase/ui/colors/constants/themes/dark.ts +++ b/frontend/src/metabase/ui/colors/constants/themes/dark.ts @@ -35,6 +35,7 @@ export const METABASE_DARK_THEME: MetabaseThemeV2 = { overlay: baseColors.orionAlpha[70], "background-error": baseColors.lobster[90], "background-success": baseColors.palm[90], + "background-success-secondary": baseColors.palm[70], brand: baseColors.blue[40], "brand-hover": baseColors.brand[30], danger: baseColors.lobster[50], diff --git a/frontend/src/metabase/ui/colors/constants/themes/light.ts b/frontend/src/metabase/ui/colors/constants/themes/light.ts index cad17198f2b8..01a6bf89260b 100644 --- a/frontend/src/metabase/ui/colors/constants/themes/light.ts +++ b/frontend/src/metabase/ui/colors/constants/themes/light.ts @@ -35,6 +35,7 @@ export const METABASE_LIGHT_THEME: MetabaseThemeV2 = { overlay: baseColors.orionAlpha[60], "background-error": baseColors.lobster[10], "background-success": baseColors.palm[5], + "background-success-secondary": baseColors.palm[20], brand: baseColors.blue[40], "brand-hover": baseColors.brand[50], danger: baseColors.lobster[50], diff --git a/frontend/src/metabase/ui/colors/types/color-keys.ts b/frontend/src/metabase/ui/colors/types/color-keys.ts index 870df6ec7206..9ecc784fb9ba 100644 --- a/frontend/src/metabase/ui/colors/types/color-keys.ts +++ b/frontend/src/metabase/ui/colors/types/color-keys.ts @@ -29,6 +29,7 @@ export type MetabaseColorKey = | "background-secondary-inverse" | "background-selected" | "background-success" + | "background-success-secondary" | "background-tertiary" | "background-tertiary-inverse" | "background-warning" From c2ccfdb6eeb324cf6aff0192eb09b449d0ae103e Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 26 May 2026 17:43:28 +0300 Subject: [PATCH 54/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Security=20c?= =?UTF-8?q?enter:=20send=20test=20notifications=20to=20unsaved=20recipient?= =?UTF-8?q?s"=20(#74762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security center: send test notifications to unsaved recipients (#74339) * feat: pass explicit config * api: build config * update FE * Update enterprise/frontend/src/metabase-enterprise/security_center/hooks/use-notification-config.ts * handle FE review comments * extend test --------- Co-authored-by: Nicola Mometto Co-authored-by: Kamil Mielnik --- .../security_center/api.clj | 22 ++++- .../security_center/notification.clj | 86 ++++++++-------- .../security_center/api_test.clj | 49 +++++++++- .../security_center/notification_test.clj | 33 ++++++- .../NotificationChannelConfigModal.tsx | 9 +- ...tificationChannelConfigModal.unit.spec.tsx | 98 +++++++++++++++++++ .../hooks/use-notification-config.ts | 41 +++++--- .../src/metabase-types/api/security-center.ts | 7 ++ frontend/src/metabase/api/security-center.ts | 13 ++- 9 files changed, 288 insertions(+), 70 deletions(-) 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/notification.clj b/enterprise/backend/src/metabase_enterprise/security_center/notification.clj index f3c969ca3ca2..f73396b8174d 100644 --- a/enterprise/backend/src/metabase_enterprise/security_center/notification.clj +++ b/enterprise/backend/src/metabase_enterprise/security_center/notification.clj @@ -51,39 +51,39 @@ (= permissions_group_id admin-group-id))) recipients)))) -(defn- email-recipients - "Resolve email recipients from the `security-center-email-recipients` setting. - 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." - [] - (let [raw (or (settings/security-center-email-recipients) []) - configured (or (some-> (not-empty raw) (t2/hydrate :recipients-detail)) - []) +(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 configured {:type :notification-recipient/raw-value - :details {:value admin-email}}) + (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))) @@ -91,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." @@ -123,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`. @@ -156,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/test/metabase_enterprise/security_center/api_test.clj b/enterprise/backend/test/metabase_enterprise/security_center/api_test.clj index 6de3c81ba0e7..8334dbd12510 100644 --- a/enterprise/backend/test/metabase_enterprise/security_center/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/security_center/api_test.clj @@ -4,6 +4,7 @@ [metabase-enterprise.security-center.fetch :as fetch] [metabase-enterprise.security-center.matching :as matching] [metabase-enterprise.security-center.notification :as notification] + [metabase-enterprise.security-center.settings :as settings] [metabase.premium-features.core :as premium-features] [metabase.premium-features.token-check :as token-check] [metabase.test :as mt] @@ -181,12 +182,54 @@ (mt/with-premium-features #{:admin-security-center} (testing "non-superuser gets 403" (mt/user-http-request :rasta :post 403 "ee/security-center/test-notification")) - (testing "superuser can send test notification" + (testing "superuser can send test notification with no body — falls back to saved settings" (with-redefs [notification/send-test-notification! (constantly nil)] (is (= {:success true} (mt/user-http-request :crowberto :post 200 "ee/security-center/test-notification"))))) (testing "returns 400 when no channels are configured" (with-redefs [notification/send-test-notification! - (fn [] (throw (ex-info "No notification channels are configured." - {:status-code 400})))] + (fn [_] (throw (ex-info "No notification channels are configured." + {:status-code 400})))] (mt/user-http-request :crowberto :post 400 "ee/security-center/test-notification")))))) + +(deftest test-notification-uses-body-test + (testing "POST /api/ee/security-center/test-notification forwards the request body so the test reflects unsaved form state" + (mt/with-premium-features #{:admin-security-center} + (testing "body values are passed through to send-test-notification!" + (let [captured (atom nil)] + (with-redefs [notification/send-test-notification! #(reset! captured %)] + (mt/user-http-request :crowberto :post 200 "ee/security-center/test-notification" + {:email_recipients [{:type "notification-recipient/raw-value" + :details {:value "form@example.com"}} + {:type "notification-recipient/user" + :user_id (str (mt/user->id :crowberto))}] + :slack_channel "#form-channel"}) + (is (= [{:type :notification-recipient/raw-value + :details {:value "form@example.com"}} + {:type :notification-recipient/user + :user_id (mt/user->id :crowberto)}] + (:email-recipients @captured))) + (is (= "#form-channel" (:slack-channel @captured)))))) + (testing "body distinguishes explicit nil slack_channel (disable) from missing key (fall back to setting)" + (let [captured (atom nil)] + (with-redefs [notification/send-test-notification! #(reset! captured %) + settings/security-center-slack-channel (constantly "#saved-channel")] + (mt/user-http-request :crowberto :post 200 "ee/security-center/test-notification" + {:email_recipients [{:type "notification-recipient/group" + :permissions_group_id 2}] + :slack_channel nil}) + (is (nil? (:slack-channel @captured)) "explicit nil disables Slack for the test")) + (with-redefs [notification/send-test-notification! #(reset! captured %) + settings/security-center-slack-channel (constantly "#saved-channel")] + (mt/user-http-request :crowberto :post 200 "ee/security-center/test-notification" + {:email_recipients [{:type "notification-recipient/group" + :permissions_group_id 2}]}) + (is (= "#saved-channel" (:slack-channel @captured)) "missing key falls back to saved setting")))) + (testing "missing email_recipients falls back to saved setting" + (let [captured (atom nil) + saved-emails [{:type :notification-recipient/raw-value :details {:value "saved@example.com"}}]] + (with-redefs [notification/send-test-notification! #(reset! captured %) + settings/security-center-email-recipients (constantly saved-emails) + settings/security-center-slack-channel (constantly nil)] + (mt/user-http-request :crowberto :post 200 "ee/security-center/test-notification" {}) + (is (= saved-emails (:email-recipients @captured))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj b/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj index c00d405ef9a6..e6236536b8a0 100644 --- a/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj +++ b/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj @@ -373,7 +373,7 @@ (testing "send-test-notification! sends a notification with test advisory data" (let [sent (atom nil)] (with-send-redef (fn [notif & _] (reset! sent notif)) - (notification/send-test-notification!) + (notification/send-test-notification! {:email-recipients nil :slack-channel nil}) (is (= :notification/system-event (:payload_type @sent))) (is (= :event/security-advisory-match (get-in @sent [:payload :event_topic]))) (let [obj (get-in @sent [:payload :event_info :object])] @@ -382,21 +382,44 @@ (is (re-find #"(?i)test" (:title obj))) (is (re-find #"(?i)test" (:description obj)))))))) +(deftest send-test-notification-uses-passed-config-test + (testing "send-test-notification! uses the passed config — not the saved settings" + (let [sent (atom nil) + form-emails [{:type :notification-recipient/external-email :details {:email "form@example.com"}}] + saved-emails [{:type :notification-recipient/external-email :details {:email "saved@example.com"}}] + form-slack-channel "#form-channel"] + (mt/with-temporary-setting-values [admin-email nil + slack-token-valid? true] + (with-redefs [settings/security-center-email-recipients (constantly saved-emails) + settings/security-center-slack-channel (constantly "#saved-channel")] + (with-send-redef (fn [notif & _] (reset! sent notif)) + (notification/send-test-notification! {:email-recipients form-emails + :slack-channel form-slack-channel}) + (let [email-handler (first (filter #(= :channel/email (:channel_type %)) (:handlers @sent))) + slack-handler (first (filter #(= :channel/slack (:channel_type %)) (:handlers @sent)))] + (is (= [{:type :notification-recipient/external-email + :details {:email "form@example.com"}}] + (:recipients email-handler)) + "uses the email recipients from the passed config, not the saved setting") + (is (= "#form-channel" + (get-in (first (:recipients slack-handler)) [:details :value])) + "uses the Slack channel from the passed config, not the saved setting")))))))) + (deftest send-test-notification-does-not-publish-event-test (testing "send-test-notification! does not publish an audit event" (let [published (atom [])] (with-redefs [events/publish-event! (fn [topic _] (swap! published conj topic)) channel.settings/email-configured? (constantly true) notification.send/send-notification! (constantly nil)] - (notification/send-test-notification!) + (notification/send-test-notification! {:email-recipients nil :slack-channel nil}) (is (empty? @published)))))) (deftest send-test-notification-throws-when-no-channels-test (testing "send-test-notification! throws when no channels are configured" - (with-redefs [channel.settings/email-configured? (constantly false) - settings/security-center-slack-channel (constantly nil)] + (with-redefs [channel.settings/email-configured? (constantly false)] (is (thrown-with-msg? Exception #"No notification channels are configured" - (notification/send-test-notification!)))))) + (notification/send-test-notification! {:email-recipients nil + :slack-channel nil})))))) ;;; -------------------------------------------- Notification payload ------------------------------------------------- diff --git a/enterprise/frontend/src/metabase-enterprise/security_center/components/NotificationChannelConfigModal/NotificationChannelConfigModal.tsx b/enterprise/frontend/src/metabase-enterprise/security_center/components/NotificationChannelConfigModal/NotificationChannelConfigModal.tsx index bf96af6ce38c..2b534550f556 100644 --- a/enterprise/frontend/src/metabase-enterprise/security_center/components/NotificationChannelConfigModal/NotificationChannelConfigModal.tsx +++ b/enterprise/frontend/src/metabase-enterprise/security_center/components/NotificationChannelConfigModal/NotificationChannelConfigModal.tsx @@ -6,7 +6,10 @@ import { useSetting, useToast } from "metabase/common/hooks"; import { useIsSmallScreen } from "metabase/common/hooks/use-is-small-screen"; import { Button, Flex, Group, Icon, Modal, Stack } from "metabase/ui"; -import { useNotificationConfig } from "../../hooks/use-notification-config"; +import { + serializeNotificationConfig, + useNotificationConfig, +} from "../../hooks/use-notification-config"; import { EmailChannelCard } from "./EmailChannelCard/EmailChannelCard"; import { SlackChannelCard } from "./SlackChannelCard/SlackChannelCard"; @@ -53,7 +56,7 @@ export function NotificationChannelConfigModal({ const handleSendTest = useCallback(async () => { setIsSendingTest(true); try { - await sendTestNotification().unwrap(); + await sendTestNotification(serializeNotificationConfig(config)).unwrap(); sendToast({ message: t`Test notification sent`, toastColor: "success", @@ -67,7 +70,7 @@ export function NotificationChannelConfigModal({ } finally { setIsSendingTest(false); } - }, [sendTestNotification, sendToast]); + }, [sendTestNotification, sendToast, config]); const emailHasRecipients = config.email.sendToAllAdmins || config.email.handler.recipients.length > 0; diff --git a/enterprise/frontend/src/metabase-enterprise/security_center/components/NotificationChannelConfigModal/NotificationChannelConfigModal.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/security_center/components/NotificationChannelConfigModal/NotificationChannelConfigModal.unit.spec.tsx index b985324d542c..97bfd3bb2501 100644 --- a/enterprise/frontend/src/metabase-enterprise/security_center/components/NotificationChannelConfigModal/NotificationChannelConfigModal.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/security_center/components/NotificationChannelConfigModal/NotificationChannelConfigModal.unit.spec.tsx @@ -323,6 +323,13 @@ async function getSavedSettings() { return puts.find((r) => r.url.includes("/api/setting"))?.body; } +async function getTestNotificationBody() { + const posts = await findRequests("POST"); + return posts.find((r) => + r.url.includes("/api/ee/security-center/test-notification"), + )?.body; +} + describe("NotificationChannelConfigModal — save payload", () => { describe("slack channel", () => { it("saves the selected slack channel", async () => { @@ -464,3 +471,94 @@ describe("NotificationChannelConfigModal — save payload", () => { }); }); }); + +// ── Send-test-notification payload tests (real hook + context) ────── + +describe("NotificationChannelConfigModal — test notification payload", () => { + it("sends the saved config when the form has not been touched", async () => { + setupWithRealHook({ slackConfigured: true, slackChannel: "#general" }); + + await userEvent.click( + screen.getByRole("button", { name: /Send test notification/i }), + ); + + await waitFor(async () => { + const body = await getTestNotificationBody(); + expect(body).toEqual({ + email_recipients: expect.arrayContaining([ + expect.objectContaining(ADMIN_GROUP_RECIPIENT), + ]), + slack_channel: "#general", + }); + }); + }); + + it("sends unsaved Slack channel changes (metabase#74147)", async () => { + setupWithRealHook({ slackConfigured: true, slackChannel: "#general" }); + + // Wait for the Slack channels query to resolve before interacting with + // the picker — Slack starts enabled (slackChannel is set), but the + // picker isn't rendered until channelInfo loads. + const channelInput = await screen.findByPlaceholderText( + "Pick a user or channel...", + ); + await userEvent.clear(channelInput); + await userEvent.type(channelInput, "#alerts"); + + await userEvent.click( + screen.getByRole("button", { name: /Send test notification/i }), + ); + + await waitFor(async () => { + const body = await getTestNotificationBody(); + expect(body?.slack_channel).toBe("#alerts"); + }); + }); + + it("sends null slack_channel when the user toggles Slack off without saving (metabase#74147)", async () => { + setupWithRealHook({ slackConfigured: true, slackChannel: "#general" }); + + await userEvent.click(screen.getByTestId("slack-toggle")); + + await userEvent.click( + screen.getByRole("button", { name: /Send test notification/i }), + ); + + await waitFor(async () => { + const body = await getTestNotificationBody(); + expect(body?.slack_channel).toBeNull(); + }); + }); + + it("sends unsaved 'send to all admins' toggle changes (metabase#74147)", async () => { + setupWithRealHook({ emailConfigured: true }); + + // Default state has the admin group recipient — flip the toggle off + await userEvent.click(screen.getByTestId("send-to-admins-toggle")); + await userEvent.type( + screen.getByPlaceholderText(/Enter user names or email/i), + "ad-hoc@example.com{enter}", + ); + + await userEvent.click( + screen.getByRole("button", { name: /Send test notification/i }), + ); + + await waitFor(async () => { + const body = await getTestNotificationBody(); + expect(body?.email_recipients).not.toEqual( + expect.arrayContaining([ + expect.objectContaining(ADMIN_GROUP_RECIPIENT), + ]), + ); + expect(body?.email_recipients).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "notification-recipient/raw-value", + details: { value: "ad-hoc@example.com" }, + }), + ]), + ); + }); + }); +}); diff --git a/enterprise/frontend/src/metabase-enterprise/security_center/hooks/use-notification-config.ts b/enterprise/frontend/src/metabase-enterprise/security_center/hooks/use-notification-config.ts index 1d6afcbeb77b..8561a4862122 100644 --- a/enterprise/frontend/src/metabase-enterprise/security_center/hooks/use-notification-config.ts +++ b/enterprise/frontend/src/metabase-enterprise/security_center/hooks/use-notification-config.ts @@ -17,6 +17,7 @@ import type { NotificationHandlerEmail, NotificationHandlerSlack, NotificationRecipient, + SendTestNotificationRequest, User, } from "metabase-types/api"; @@ -62,6 +63,29 @@ function isAdminGroupRecipient(r: NotificationRecipient): boolean { ); } +/** + * Flatten the modal's config into a serializable shape used by both + * POST /api/ee/security-center/test-notification (for unsaved test sends) + * and PUT /api/setting/security-center-email-recipients (for saving). + */ +export function serializeNotificationConfig( + config: NotificationConfig, +): SendTestNotificationRequest { + const adminGroupRecipient: NotificationRecipient = { + type: "notification-recipient/group", + permissions_group_id: ADMIN_GROUP_ID, + }; + const emailRecipients: NotificationRecipient[] = [ + ...(config.email.sendToAllAdmins ? [adminGroupRecipient] : []), + ...config.email.handler.recipients, + ]; + const slackChannel: string | null = + config.slack.enabled && config.slack.handler.recipients.length > 0 + ? config.slack.handler.recipients[0].details.value + : null; + return { emailRecipients, slackChannel }; +} + function configFromSettings( emailRecipients: NotificationRecipient[] | null, slackChannel: string | null, @@ -183,21 +207,8 @@ export function useNotificationConfigState(): NotificationConfigValue { }, []); const save = useCallback(async () => { - const adminGroupRecipient: NotificationRecipient = { - type: "notification-recipient/group", - permissions_group_id: ADMIN_GROUP_ID, - }; - - const emailRecipients: NotificationRecipient[] = [ - ...(config.email.sendToAllAdmins ? [adminGroupRecipient] : []), - ...config.email.handler.recipients, - ]; - - const slackChannel: string | null = - config.slack.enabled && config.slack.handler.recipients.length > 0 - ? config.slack.handler.recipients[0].details.value - : null; - + const { emailRecipients, slackChannel } = + serializeNotificationConfig(config); await updateSettings({ "security-center-email-recipients": emailRecipients, "security-center-slack-channel": slackChannel, diff --git a/frontend/src/metabase-types/api/security-center.ts b/frontend/src/metabase-types/api/security-center.ts index c3814c924b08..578642d7dc02 100644 --- a/frontend/src/metabase-types/api/security-center.ts +++ b/frontend/src/metabase-types/api/security-center.ts @@ -1,3 +1,5 @@ +import type { NotificationRecipient } from "./notification"; + export type AdvisoryMatchStatus = | "active" | "resolved" @@ -41,3 +43,8 @@ export type AcknowledgeAdvisoryResponse = { }; export type AcknowledgeAdvisoriesResponse = AcknowledgeAdvisoryResponse[]; + +export type SendTestNotificationRequest = { + emailRecipients: NotificationRecipient[]; + slackChannel: string | null; +}; diff --git a/frontend/src/metabase/api/security-center.ts b/frontend/src/metabase/api/security-center.ts index b9644be0c0a6..a62d7d810c03 100644 --- a/frontend/src/metabase/api/security-center.ts +++ b/frontend/src/metabase/api/security-center.ts @@ -4,9 +4,11 @@ import type { AcknowledgeAdvisoryResponse, AdvisoryId, ListAdvisoriesResponse, + SendTestNotificationRequest, } from "metabase-types/api"; import { listTag } from "./tags"; + export const securityCenterApi = Api.injectEndpoints({ endpoints: (builder) => ({ listSecurityAdvisories: builder.query({ @@ -41,10 +43,17 @@ export const securityCenterApi = Api.injectEndpoints({ }), invalidatesTags: [listTag("security-advisory")], }), - sendTestNotification: builder.mutation<{ success: boolean }, void>({ - query: () => ({ + sendTestNotification: builder.mutation< + { success: boolean }, + SendTestNotificationRequest + >({ + query: ({ emailRecipients, slackChannel }) => ({ method: "POST", url: "/api/ee/security-center/test-notification", + body: { + email_recipients: emailRecipients, + slack_channel: slackChannel, + }, }), }), }), From 8b77de7b3026058e1776e7f491b3142065ef1ecb Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 26 May 2026 19:23:43 +0300 Subject: [PATCH 55/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Fix=20search?= =?UTF-8?q?=20values=20remap"=20(#74771)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix search values remap (#74770) * fix custom values with labels * fix Co-authored-by: Alexander Polyankin --- src/metabase/parameters/custom_values.clj | 26 ++++++++++--------- .../parameters/custom_values_test.clj | 16 ++++++++++++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/metabase/parameters/custom_values.clj b/src/metabase/parameters/custom_values.clj index 6355c28b4c2c..872c732e81f2 100644 --- a/src/metabase/parameters/custom_values.clj +++ b/src/metabase/parameters/custom_values.clj @@ -93,18 +93,20 @@ id (pr-str field-ref) (pr-str (map (some-fn :lib/source-column-alias :name) visible-columns))))] - (let [label-column (when label-field - (or (lib/find-matching-column query -1 label-field visible-columns) - (log/warnf "Cannot get labels from Card %d: failed to find column for ref %s" - id - (pr-str label-field)))) - textual? (lib.types.isa/string? value-column) - nonempty ((if textual? lib/not-empty lib/not-null) value-column) - query-filter (cond - (some? exact-value) (lib/= value-column exact-value) - query-string (if textual? - (lib/contains (lib/lower value-column) (u/lower-case-en query-string)) - (lib/= value-column query-string)))] + (let [label-column (when label-field + (or (lib/find-matching-column query -1 label-field visible-columns) + (log/warnf "Cannot get labels from Card %d: failed to find column for ref %s" + id + (pr-str label-field)))) + search-column (or label-column value-column) + value-textual? (lib.types.isa/string? value-column) + search-textual? (lib.types.isa/string? search-column) + nonempty ((if value-textual? lib/not-empty lib/not-null) value-column) + query-filter (cond + (some? exact-value) (lib/= value-column exact-value) + query-string (if search-textual? + (lib/ignore-case (lib/contains search-column query-string)) + (lib/= search-column query-string)))] (-> query (lib/limit *max-rows*) (lib/filter nonempty) diff --git a/test/metabase/parameters/custom_values_test.clj b/test/metabase/parameters/custom_values_test.clj index 9fa28d05558e..edeed3bad824 100644 --- a/test/metabase/parameters/custom_values_test.clj +++ b/test/metabase/parameters/custom_values_test.clj @@ -53,6 +53,22 @@ [: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] From e92d71f2aef384ef085eb5c68363a519a331dc85 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 26 May 2026 22:16:07 +0300 Subject: [PATCH 56/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Allow=20Admi?= =?UTF-8?q?ns=20to=20purchase=20Metabot=20AI"=20(#74786)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow Admins to purchase Metabot AI (#74602) * Allow Admins to purchase Metabot AI * update unit tests Co-authored-by: Ryan Laurie <30528226+iethree@users.noreply.github.com> --- .../metabase_enterprise/cloud_add_ons/api.clj | 8 - .../cloud_add_ons/api_test.clj | 86 ++-- .../MetabotAdmin/MetabaseAIProviderSetup.tsx | 15 +- .../MetabaseAIProviderSetup.unit.spec.ts | 76 ---- .../MetabaseAIProviderSetup.unit.spec.tsx | 399 ++++++++++++++++++ .../MetabotAdmin/MetabotSetup.unit.spec.tsx | 30 +- 6 files changed, 455 insertions(+), 159 deletions(-) delete mode 100644 enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.unit.spec.ts create mode 100644 enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.unit.spec.tsx 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/test/metabase_enterprise/cloud_add_ons/api_test.clj b/enterprise/backend/test/metabase_enterprise/cloud_add_ons/api_test.clj index e9ba9c4459ac..76fba5ef9b98 100644 --- a/enterprise/backend/test/metabase_enterprise/cloud_add_ons/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/cloud_add_ons/api_test.clj @@ -24,47 +24,38 @@ (is (=? "Can only access Store API for Metabase Cloud instances." (mt/user-http-request :crowberto :post 400 "ee/cloud-add-ons/metabase-ai" {:terms_of_service true}))))) - (testing "requires current user being a store user" - (mt/with-premium-features #{:hosting} - (is (=? "Only Metabase Store users can purchase add-ons." - (mt/user-http-request :crowberto :post 403 "ee/cloud-add-ons/metabase-ai" - {:terms_of_service true}))))) (testing "when all conditions are met" - (mt/with-temp [:model/User user {:is_superuser true}] - (mt/with-premium-features #{:hosting :audit-app} - ;; FIXME: With `(mt/with-temporary-setting-values [token-status {:store-users [{:email (:email user)}]}])`, - ;; `(premium-features/token-status)` still returns `nil`; thus resort to `with-redefs`: - (with-redefs [premium-features/token-status (constantly {:store-users [{:email (:email user)}]})] - (doseq [[status-code error-message] {404 "Could not establish a connection to Metabase Cloud." - 403 "Could not establish a connection to Metabase Cloud." - 401 "Could not establish a connection to Metabase Cloud." - 400 "Could not purchase this add-on."}] - (testing (format "passes through HTTP status %d from Store API" status-code) - (with-redefs [hm.client/call (fn [& _] (throw (ex-info "TEST" {:status status-code})))] - (is (=? error-message - (mt/user-http-request user :post status-code "ee/cloud-add-ons/metabase-ai" - {:terms_of_service true})))))) - (testing "responds with HTTP status 500 for other errors from Store API" - (with-redefs [hm.client/call (fn [& _] (throw (ex-info "TEST" {})))] - (is (=? "Unexpected error" - (mt/user-http-request user :post 500 "ee/cloud-add-ons/metabase-ai" - {:terms_of_service true}))))) - (testing "succeeds" - (let [{store-api-proxy :proxy store-api-calls :calls} (semantic.tu/spy (constantly nil)) - {clear-token-cache-proxy :proxy clear-token-cache-calls :calls} (semantic.tu/spy premium-features/clear-cache!)] - (with-redefs [hm.client/call store-api-proxy - premium-features/clear-cache! clear-token-cache-proxy] - (is (=? {} - (mt/user-http-request user :post 200 "ee/cloud-add-ons/metabase-ai" - {:terms_of_service true}))) - (is (not-empty @store-api-calls) - "Store API was called") - (is (not-empty @clear-token-cache-calls) - "Token cache was cleared") - (testing "audit log entry generated" - (let [{:keys [details user_id]} (mt/latest-audit-log-entry "cloud-add-on-purchase")] - (is (= (:id user) user_id)) - (is (= {:add-on {:product-type "metabase-ai"}} details))))))))))))) + (mt/with-premium-features #{:hosting :audit-app} + (doseq [[status-code error-message] {404 "Could not establish a connection to Metabase Cloud." + 403 "Could not establish a connection to Metabase Cloud." + 401 "Could not establish a connection to Metabase Cloud." + 400 "Could not purchase this add-on."}] + (testing (format "passes through HTTP status %d from Store API" status-code) + (with-redefs [hm.client/call (fn [& _] (throw (ex-info "TEST" {:status status-code})))] + (is (=? error-message + (mt/user-http-request :crowberto :post status-code "ee/cloud-add-ons/metabase-ai" + {:terms_of_service true})))))) + (testing "responds with HTTP status 500 for other errors from Store API" + (with-redefs [hm.client/call (fn [& _] (throw (ex-info "TEST" {})))] + (is (=? "Unexpected error" + (mt/user-http-request :crowberto :post 500 "ee/cloud-add-ons/metabase-ai" + {:terms_of_service true}))))) + (testing "succeeds" + (let [{store-api-proxy :proxy store-api-calls :calls} (semantic.tu/spy (constantly nil)) + {clear-token-cache-proxy :proxy clear-token-cache-calls :calls} (semantic.tu/spy premium-features/clear-cache!)] + (with-redefs [hm.client/call store-api-proxy + premium-features/clear-cache! clear-token-cache-proxy] + (is (=? {} + (mt/user-http-request :crowberto :post 200 "ee/cloud-add-ons/metabase-ai" + {:terms_of_service true}))) + (is (not-empty @store-api-calls) + "Store API was called") + (is (not-empty @clear-token-cache-calls) + "Token cache was cleared") + (testing "audit log entry generated" + (let [{:keys [details user_id]} (mt/latest-audit-log-entry "cloud-add-on-purchase")] + (is (= (mt/user->id :crowberto) user_id)) + (is (= {:add-on {:product-type "metabase-ai"}} details))))))))))) (deftest ^:sequential post-transforms-test (doseq [[product-type feature] [["python-execution" :transforms-python] @@ -86,18 +77,11 @@ (mt/with-premium-features #{:hosting feature} (is (=? "Can only purchase add-ons for eligible subscriptions." (mt/user-http-request :crowberto :post 400 (str "ee/cloud-add-ons/" product-type) {}))))) - (testing "requires current user being a store user" - (mt/with-temp [:model/User user {:is_superuser true}] - (mt/with-premium-features #{:hosting} - (is (=? "Only Metabase Store users can purchase add-ons." - (mt/user-http-request user :post 403 (str "ee/cloud-add-ons/" product-type) {})))))) (testing "succeeds when all conditions are met" - (mt/with-temp [:model/User user {:is_superuser true}] - (mt/with-premium-features #{:hosting} - (with-redefs [premium-features/token-status (constantly {:store-users [{:email (:email user)}]}) - hm.client/call (constantly nil)] - (is (=? {} - (mt/user-http-request user :post 200 (str "ee/cloud-add-ons/" product-type) {})))))))))) + (mt/with-premium-features #{:hosting} + (with-redefs [hm.client/call (constantly nil)] + (is (=? {} + (mt/user-http-request :crowberto :post 200 (str "ee/cloud-add-ons/" product-type) {}))))))))) (deftest ^:sequential delete-product-type-test (testing "DELETE /api/ee/cloud-add-ons/metabase-ai-managed" diff --git a/enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.tsx b/enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.tsx index 441ee6682430..4fa252ba4a87 100644 --- a/enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.tsx +++ b/enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.tsx @@ -12,7 +12,7 @@ import { useMetabotSetupContext } from "metabase/metabot/components/MetabotAdmin import { MetabotManagedProviderLimitActions } from "metabase/metabot/components/MetabotManagedProviderLimit"; import type { MetabaseAIProviderSetupProps } from "metabase/plugins"; import { useSelector } from "metabase/redux"; -import { getStoreUsers } from "metabase/selectors/store-users"; +import { getUserIsAdmin } from "metabase/selectors/user"; import { Anchor, Box, @@ -65,7 +65,7 @@ export function MetabaseAIProviderSetup({ !!hasPremiumFeature(METABOT_V3_FEATURE); const isConfigured = !!useSetting("llm-metabot-configured?"); - const { isStoreUser, anyStoreUserEmailAddress } = useSelector(getStoreUsers); + const isAdmin = useSelector(getUserIsAdmin); const [updateMetabotSettings, updateMetabotSettingsResult] = useUpdateMetabotSettingsMutation(); @@ -107,7 +107,7 @@ export function MetabaseAIProviderSetup({ hasMetabaseManagedAiProviderFeature, hasDeprecatedMetabaseAiProvider, isConfigured, - isStoreUser, + isAdmin, }) .with({ isConfigured: true }, () => null) .with({ hasMetabaseManagedAiProviderFeature: true }, () => handleConnect) @@ -116,7 +116,7 @@ export function MetabaseAIProviderSetup({ { hasMetabaseManagedAiProviderFeature: false, hasAcceptedTerms: false }, () => null, ) - .with({ isStoreUser: false }, () => null) + .with({ isAdmin: false }, () => null) .otherwise(() => handleMetabasePurchase); const onDisconnect = useCallback(async () => { @@ -228,7 +228,7 @@ export function MetabaseAIProviderSetup({ hasDeprecatedMetabaseAiProvider, hasMetabaseManagedAiProviderFeature, offerMetabaseManagedAi, - isStoreUser, + isAdmin, }) .with( { @@ -244,10 +244,9 @@ export function MetabaseAIProviderSetup({ ) .with({ hasDeprecatedMetabaseAiProvider: true }, () => null) .with({ hasMetabaseManagedAiProviderFeature: true }, () => null) - .with({ isStoreUser: false }, () => ( + .with({ isAdmin: false }, () => ( - {/* eslint-disable-next-line metabase/no-literal-metabase-strings -- This string only shows for admins. */} - {t`Please ask a Metabase Store Admin${anyStoreUserEmailAddress && ` (${anyStoreUserEmailAddress})`} of your organization to enable this for you.`} + {t`Please ask an Admin user to enable this for you.`} )) .otherwise(() => ( diff --git a/enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.unit.spec.ts b/enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.unit.spec.ts deleted file mode 100644 index 5b8d1fbb8a49..000000000000 --- a/enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.unit.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { createElement } from "react"; - -import { render, screen } from "__support__/ui"; -import type { MetabotUsageResponse } from "metabase-enterprise/api"; - -import type { MetabaseManagedAiPricing } from "../../useMetabaseManagedAiPricing"; - -import { - MetabasePricingText, - getMetabaseUsageCost, -} from "./MetabaseAIProviderSetup"; - -const PRICING: MetabaseManagedAiPricing = { - price: "$3.00", - unit: "1M", - pricePerUnit: 3, - unitCount: 1_000_000, - freeUnits: null, -}; - -function createUsage(tokens: number, freeTokens: number): MetabotUsageResponse { - return { - tokens, - free_tokens: freeTokens, - updated_at: null, - is_locked: false, - }; -} - -describe("getMetabaseUsageCost", () => { - it.each([ - { tokens: 100, freeTokens: 100 }, - { tokens: 50, freeTokens: 100 }, - ])( - "returns 0 when tokens ($tokens) are less than or equal to free tokens ($freeTokens)", - ({ tokens, freeTokens }) => { - expect( - getMetabaseUsageCost(createUsage(tokens, freeTokens), PRICING), - ).toBe(0); - }, - ); - - it("calculates cost only for tokens above the free allocation", () => { - expect( - getMetabaseUsageCost(createUsage(3_000_000, 1_000_000), PRICING), - ).toBe(6); - }); -}); - -describe("MetabasePricingText", () => { - it("shows the free-token pricing message when free units are available", () => { - render( - createElement(MetabasePricingText, { - pricing: { ...PRICING, freeUnits: "1M" }, - }), - ); - - expect( - screen.getByText( - "You get 1M tokens for free. Price per token afterward - $3.00 per 1M tokens", - ), - ).toBeInTheDocument(); - }); - - it("shows the standard pricing message when no free units are available", () => { - render( - createElement(MetabasePricingText, { - pricing: { ...PRICING, freeUnits: null }, - }), - ); - - expect( - screen.getByText("Price per token - $3.00 per 1M tokens"), - ).toBeInTheDocument(); - }); -}); diff --git a/enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.unit.spec.tsx new file mode 100644 index 000000000000..88ecd14490ff --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/metabot/components/MetabotAdmin/MetabaseAIProviderSetup.unit.spec.tsx @@ -0,0 +1,399 @@ +import userEvent from "@testing-library/user-event"; +import fetchMock from "fetch-mock"; + +import { setupEnterpriseOnlyPlugin } from "__support__/enterprise"; +import { + setupMetabaseManagedAiEndpoints, + setupPropertiesEndpoints, +} from "__support__/server-mocks"; +import { mockSettings } from "__support__/settings"; +import { renderWithProviders, screen, waitFor } from "__support__/ui"; +import { reinitialize } from "metabase/plugins"; +import { createMockState } from "metabase/redux/store/mocks"; +import type { MetabotUsageResponse } from "metabase-enterprise/api"; +import type { TokenStatusFeature } from "metabase-types/api"; +import { + createMockSettings, + createMockTokenFeatures, + createMockTokenStatus, + createMockUser, +} from "metabase-types/api/mocks"; + +import type { MetabaseManagedAiPricing } from "../../useMetabaseManagedAiPricing"; + +import { + MetabaseAIProviderSetup, + MetabasePricingText, + getMetabaseUsageCost, +} from "./MetabaseAIProviderSetup"; + +type MetabotUsageQuota = { + tokens?: number | null; + free_tokens?: number | null; + is_locked?: boolean; +}; + +type SetupOptions = { + isAdmin?: boolean; + isConfigured?: boolean; + hasManagedAi?: boolean; + hasDeprecatedAi?: boolean; + offerMetabaseManagedAi?: boolean; + metabasePricePerUnit?: number; + pauseAddOnsResponse?: boolean; + metabotUsageQuota?: MetabotUsageQuota | null; +}; + +function setup({ + isAdmin = true, + isConfigured = false, + hasManagedAi = false, + hasDeprecatedAi = false, + offerMetabaseManagedAi = false, + metabasePricePerUnit = 3.0, + pauseAddOnsResponse = false, + metabotUsageQuota = null, +}: SetupOptions = {}) { + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + + const tokenFeatures: TokenStatusFeature[] = []; + if (hasManagedAi) { + tokenFeatures.push("metabase-ai-managed"); + } + if (hasDeprecatedAi) { + tokenFeatures.push("metabot-v3"); + } + if (offerMetabaseManagedAi) { + tokenFeatures.push("offer-metabase-ai-managed"); + } + + const sessionProperties = createMockSettings({ + "is-hosted?": true, + "llm-metabot-configured?": isConfigured, + "token-features": createMockTokenFeatures({ + hosting: true, + "offer-metabase-ai-managed": offerMetabaseManagedAi, + "metabase-ai-managed": hasManagedAi, + "metabot-v3": hasDeprecatedAi, + }), + "token-status": createMockTokenStatus({ + features: tokenFeatures, + }), + }); + + setupPropertiesEndpoints(sessionProperties); + + setupMetabaseManagedAiEndpoints({ + metabasePricePerUnit, + metabotUsageQuota: metabotUsageQuota + ? { + tokens: metabotUsageQuota.tokens ?? null, + free_tokens: metabotUsageQuota.free_tokens ?? null, + is_locked: metabotUsageQuota.is_locked ?? false, + updated_at: null, + } + : null, + }); + + if (pauseAddOnsResponse) { + fetchMock.removeRoute("path:/api/ee/cloud-add-ons/addons"); + fetchMock.get( + "path:/api/ee/cloud-add-ons/addons", + () => new Promise(() => undefined), + ); + } + + fetchMock.post( + "path:/api/premium-features/token/refresh", + () => sessionProperties["token-status"], + ); + + const storeInitialState = createMockState({ + currentUser: createMockUser({ is_superuser: isAdmin }), + settings: mockSettings(sessionProperties), + }); + + setupEnterpriseOnlyPlugin("metabot"); + + return renderWithProviders(, { + storeInitialState, + }); +} + +const PRICING: MetabaseManagedAiPricing = { + price: "$3.00", + unit: "1M", + pricePerUnit: 3, + unitCount: 1_000_000, + freeUnits: null, +}; + +function createUsage( + tokens: number | null, + freeTokens: number | null, +): MetabotUsageResponse { + return { + tokens, + free_tokens: freeTokens, + updated_at: null, + is_locked: false, + }; +} + +describe("MetabaseAIProviderSetup", () => { + afterEach(() => { + reinitialize(); + }); + + describe("disconnected state", () => { + it("shows the Metabase AI service introduction with loaded pricing", async () => { + setup(); + + expect( + await screen.findByText("About Metabase AI service"), + ).toBeInTheDocument(); + expect( + screen.getByText(/The simplest way to get started with AI in Metabase/), + ).toBeInTheDocument(); + expect( + await screen.findByText("Price per token - $3.00 per 1M tokens"), + ).toBeInTheDocument(); + }); + + it("does not render pricing text while the add-ons request is in flight", () => { + setup({ pauseAddOnsResponse: true }); + + expect(screen.queryByText(/Price per token/i)).not.toBeInTheDocument(); + expect(screen.getByText("About Metabase AI service")).toBeInTheDocument(); + }); + + it("shows the Terms of Service checkbox when no managed AI feature is enabled", async () => { + setup(); + + const checkbox = await screen.findByRole("checkbox", { + name: /I agree with the Metabase AI Service/i, + }); + expect(checkbox).not.toBeChecked(); + expect( + screen.getByRole("link", { name: "Terms of Service" }), + ).toHaveAttribute("href", "https://www.metabase.com/license/hosting"); + }); + + it("toggles the Terms of Service checkbox when clicked", async () => { + setup(); + + const checkbox = await screen.findByRole("checkbox", { + name: /I agree with the Metabase AI Service/i, + }); + expect(checkbox).not.toBeChecked(); + + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + }); + + it("hides the Terms checkbox and shows the legacy pricing notice when migrating from tiered AI to managed AI", async () => { + setup({ + hasDeprecatedAi: true, + offerMetabaseManagedAi: true, + }); + + expect( + await screen.findByText( + "You're on legacy tiered AI pricing today. On your next billing cycle, you'll switch to metered AI pricing.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { + name: /I agree with the Metabase AI Service/i, + }), + ).not.toBeInTheDocument(); + }); + + it("hides the Terms checkbox when the user already has the managed AI provider feature", async () => { + setup({ hasManagedAi: true }); + + expect( + await screen.findByText("About Metabase AI service"), + ).toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { + name: /I agree with the Metabase AI Service/i, + }), + ).not.toBeInTheDocument(); + }); + + it("hides the Terms checkbox when the user has the deprecated AI provider without a migration offer", async () => { + setup({ + hasDeprecatedAi: true, + offerMetabaseManagedAi: false, + }); + + expect( + await screen.findByText("About Metabase AI service"), + ).toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { + name: /I agree with the Metabase AI Service/i, + }), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/legacy tiered AI/i)).not.toBeInTheDocument(); + }); + + it("asks non-admin users to contact an admin instead of showing the Terms checkbox", async () => { + setup({ isAdmin: false }); + + expect( + await screen.findByText( + "Please ask an Admin user to enable this for you.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { + name: /I agree with the Metabase AI Service/i, + }), + ).not.toBeInTheDocument(); + }); + }); + + describe("connected state with managed AI feature", () => { + it("shows the locked state UI when the user has run out of tokens", async () => { + setup({ + isConfigured: true, + hasManagedAi: true, + metabotUsageQuota: { + tokens: 2_000_000, + free_tokens: 0, + is_locked: true, + }, + }); + + expect( + await screen.findByText("You've run out of AI service tokens"), + ).toBeInTheDocument(); + expect( + screen.queryByText("Current billing cycle"), + ).not.toBeInTheDocument(); + }); + + it("shows the free trial usage row when within the free-token allowance", async () => { + setup({ + isConfigured: true, + hasManagedAi: true, + metabotUsageQuota: { tokens: 250_000, free_tokens: 1_000_000 }, + }); + + expect(await screen.findByText("Included use")).toBeInTheDocument(); + expect(screen.getByText("Free trial tokens")).toBeInTheDocument(); + expect(screen.getByText("250,000 / 1,000,000")).toBeInTheDocument(); + expect( + await screen.findByText("Price per token afterward"), + ).toBeInTheDocument(); + expect( + screen.queryByText("Current billing cycle"), + ).not.toBeInTheDocument(); + }); + + it("shows the current billing cycle UI once free tokens are exhausted", async () => { + setup({ + isConfigured: true, + hasManagedAi: true, + metabasePricePerUnit: 3.0, + metabotUsageQuota: { tokens: 3_000_000, free_tokens: 1_000_000 }, + }); + + expect( + await screen.findByText("Current billing cycle"), + ).toBeInTheDocument(); + expect(screen.getByText("Total tokens")).toBeInTheDocument(); + expect(screen.getByText("3,000,000")).toBeInTheDocument(); + expect(screen.getByText("Total cost")).toBeInTheDocument(); + // (3M - 1M) tokens at $3 per 1M tokens = $6.00 + expect(await screen.findByText("$6.00")).toBeInTheDocument(); + expect(screen.queryByText("Included use")).not.toBeInTheDocument(); + }); + + it("refreshes the token status when the connected card mounts", async () => { + setup({ + isConfigured: true, + hasManagedAi: true, + metabotUsageQuota: { tokens: 0, free_tokens: 0 }, + }); + + await waitFor(() => { + expect( + fetchMock.callHistory.called( + "path:/api/premium-features/token/refresh", + ), + ).toBe(true); + }); + }); + }); + + describe("connected state with deprecated AI provider", () => { + it("shows the legacy tiered pricing notice when the user can still migrate", async () => { + setup({ + isConfigured: true, + hasDeprecatedAi: true, + offerMetabaseManagedAi: true, + metabotUsageQuota: { tokens: 1_000_000, free_tokens: 0 }, + }); + + expect( + await screen.findByText( + /You're on legacy tiered AI pricing today\. On your next billing cycle/i, + ), + ).toBeInTheDocument(); + // The managed-feature usage rows should not appear without the + // metabase-ai-managed feature. + expect(screen.queryByText("Total tokens")).not.toBeInTheDocument(); + expect( + screen.queryByText("You've run out of AI service tokens"), + ).not.toBeInTheDocument(); + }); + }); +}); + +describe("getMetabaseUsageCost", () => { + it.each([ + { tokens: 100, freeTokens: 100 }, + { tokens: 50, freeTokens: 100 }, + ])( + "returns 0 when tokens ($tokens) are less than or equal to free tokens ($freeTokens)", + ({ tokens, freeTokens }) => { + expect( + getMetabaseUsageCost(createUsage(tokens, freeTokens), PRICING), + ).toBe(0); + }, + ); + + it("calculates cost only for tokens above the free allocation", () => { + expect( + getMetabaseUsageCost(createUsage(3_000_000, 1_000_000), PRICING), + ).toBe(6); + }); +}); + +describe("MetabasePricingText", () => { + it("shows the free-token pricing message when free units are available", () => { + renderWithProviders( + , + ); + + expect( + screen.getByText( + "You get 1M tokens for free. Price per token afterward - $3.00 per 1M tokens", + ), + ).toBeInTheDocument(); + }); + + it("shows the standard pricing message when no free units are available", () => { + renderWithProviders( + , + ); + + expect( + screen.getByText("Price per token - $3.00 per 1M tokens"), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/metabot/components/MetabotAdmin/MetabotSetup.unit.spec.tsx b/frontend/src/metabase/metabot/components/MetabotAdmin/MetabotSetup.unit.spec.tsx index d1235c7ce12d..9cbcd6133d20 100644 --- a/frontend/src/metabase/metabot/components/MetabotAdmin/MetabotSetup.unit.spec.tsx +++ b/frontend/src/metabase/metabot/components/MetabotAdmin/MetabotSetup.unit.spec.tsx @@ -24,6 +24,7 @@ import { createMockSettings, createMockTokenFeatures, createMockTokenStatus, + createMockUser, } from "metabase-types/api/mocks"; import { MetabotSetup, MetabotSetupInner } from "./MetabotSetup"; @@ -116,7 +117,7 @@ type SetupOptions = { providerSettingEnvName?: string; apiKeySettingIsEnv?: boolean; apiKeySettingEnvName?: string; - isStoreUser?: boolean; + isAdmin?: boolean; anyStoreUserEmailAddress?: string; metabasePricePerUnit?: number; metabaseBillingPeriodMonths?: number; @@ -147,8 +148,7 @@ async function setup({ providerSettingEnvName = "LLM_METABOT_PROVIDER", apiKeySettingIsEnv = false, apiKeySettingEnvName = "LLM_ANTHROPIC_API_KEY", - isStoreUser = isHosted, - anyStoreUserEmailAddress = "store-admin@metabase.test", + isAdmin = false, metabasePricePerUnit = 3.75, metabaseBillingPeriodMonths = 1, metabotUsageQuotas = null, @@ -201,11 +201,6 @@ async function setup({ "token-features": createTokenFeatureFlags(tokenStatusFeatures), "token-status": createMockTokenStatus({ features: tokenStatusFeatures, - "store-users": isStoreUser - ? [{ email: "user@metabase.test" }] - : anyStoreUserEmailAddress - ? [{ email: anyStoreUserEmailAddress }] - : [], }), }); @@ -382,7 +377,9 @@ async function setup({ return 204; }); - const storeInitialState = { settings }; + const user = createMockUser({ is_superuser: isAdmin }); + + const storeInitialState = { settings, currentUser: user }; const view = renderAsModal ? renderWithProviders(, { storeInitialState, @@ -812,6 +809,7 @@ describe("MetabotSetup", () => { it("shows pricing details in a tooltip for the Metabase provider", async () => { await setup({ isHosted: true, + isAdmin: true, savedProviderValue: "metabase/anthropic/claude-sonnet-4-6", isConfigured: false, }); @@ -833,19 +831,19 @@ describe("MetabotSetup", () => { ).toHaveAttribute("href", "https://www.metabase.com/license/hosting"); }); - it("shows a contact-admin notice for non-store users on the Metabase provider", async () => { + it("shows a contact-admin notice for non-admin users on the Metabase provider", async () => { await setup({ isHosted: true, savedProviderValue: "metabase/anthropic/claude-sonnet-4-6", isConfigured: false, - isStoreUser: false, + isAdmin: false, anyStoreUserEmailAddress: "store-admin@metabase.test", }); await selectProvider("Metabase"); expect( await screen.findByText( - "Please ask a Metabase Store Admin (store-admin@metabase.test) of your organization to enable this for you.", + "Please ask an Admin user to enable this for you.", ), ).toBeInTheDocument(); expect( @@ -860,7 +858,7 @@ describe("MetabotSetup", () => { isHosted: true, savedProviderValue: null, isConfigured: false, - isStoreUser: false, + isAdmin: false, tokenStatusFeatures: ["metabase-ai-managed"], updateResponse: { value: "metabase/anthropic/claude-sonnet-4-6", @@ -913,7 +911,7 @@ describe("MetabotSetup", () => { isHosted: true, savedProviderValue: null, isConfigured: false, - isStoreUser: false, + isAdmin: false, tokenStatusFeatures: ["metabase-ai-managed"], updateResponse: { value: "metabase/anthropic/claude-sonnet-4-6", @@ -955,7 +953,7 @@ describe("MetabotSetup", () => { isHosted: true, savedProviderValue: "metabase/anthropic/claude-sonnet-4-6", isConfigured: false, - isStoreUser: true, + isAdmin: true, tokenStatusFeatures: [], refreshedTokenStatusFeatures: ["metabase-ai-managed"], deferPurchaseCloudAddOnResponse: true, @@ -1130,7 +1128,7 @@ describe("MetabotSetup", () => { isHosted: true, savedProviderValue: "metabase/anthropic/claude-sonnet-4-6", isConfigured: false, - isStoreUser: true, + isAdmin: true, tokenStatusFeatures: [], refreshedTokenStatusFeatures: ["metabase-ai-managed"], deferPurchaseCloudAddOnResponse: true, From 280f65c6d71da67701f45a4e1eaf253c20f1fcd6 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 26 May 2026 22:52:47 +0300 Subject: [PATCH 57/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"docs=20-=20b?= =?UTF-8?q?itbucket=20remote=20sync"=20(#74792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs - bitbucket remote sync (#74772) bitbucket tokens Co-authored-by: Jeff Bruemmer --- .../installation-and-operation/remote-sync.md | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) 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 From c809dec3d7414aa3b7d41e5ee7ba0113fa5d3352 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 26 May 2026 23:11:36 +0300 Subject: [PATCH 58/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"MCP:=20signa?= =?UTF-8?q?l=20tool-list=20changes=20to=20clients=20(GHY-3661)"=20(#74787)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: bryan --- src/metabase/mcp/api.clj | 20 +++++-- src/metabase/mcp/tools.clj | 23 ++++++++ test/metabase/mcp/api_test.clj | 2 +- test/metabase/mcp/tools_test.clj | 96 ++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 test/metabase/mcp/tools_test.clj diff --git a/src/metabase/mcp/api.clj b/src/metabase/mcp/api.clj index 1c8fcaf4f3bf..b2d8f91f48ed 100644 --- a/src/metabase/mcp/api.clj +++ b/src/metabase/mcp/api.clj @@ -77,7 +77,7 @@ (jsonrpc-response id {:protocolVersion protocol-version - :capabilities {:tools {} :resources {}} + :capabilities {:tools {:listChanged true} :resources {}} :serverInfo server-info})) (defn- handle-tools-list [id _params token-scopes] @@ -257,10 +257,18 @@ :else (json-response 200 responses)))))) +(def ^:private tools-list-changed-notification + {:jsonrpc "2.0" :method "notifications/tools/list_changed"}) + (defn- handle-get - "Handle a GET request for SSE stream (keepalive for server-initiated notifications)." + "Handle a GET request for SSE stream (keepalive for server-initiated notifications). + Polls the tool manifest hash on each keepalive tick — if the visible tool set has + changed since the previous tick, emits an MCP `notifications/tools/list_changed` + message so the client knows to refetch `tools/list`. Stateless: each connection + tracks its own last-seen hash; no shared registry." [_user-id request respond raise] (let [session-id (get-in request [:headers "mcp-session-id"]) + token-scopes (:token-scopes request) session-err (session-error-response session-id)] (cond (some? session-err) @@ -273,12 +281,16 @@ :status 200} [os canceled-chan] (let [writer (BufferedWriter. (OutputStreamWriter. os StandardCharsets/UTF_8))] - (loop [] + (loop [last-hash (mcp.tools/tools-hash token-scopes)] (when-not (a/poll! canceled-chan) (.write writer ": keepalive\n\n") (.flush writer) (Thread/sleep 30000) - (recur)))))] + (let [current-hash (mcp.tools/tools-hash token-scopes)] + (when (not= current-hash last-hash) + (.write writer ^String (sse-body [tools-list-changed-notification])) + (.flush writer)) + (recur current-hash))))))] (compojure.response/send* resp request respond raise))))) (defn- handle-delete diff --git a/src/metabase/mcp/tools.clj b/src/metabase/mcp/tools.clj index f98b0f3cbd4c..000e7178f343 100644 --- a/src/metabase/mcp/tools.clj +++ b/src/metabase/mcp/tools.clj @@ -129,6 +129,29 @@ (select-keys tool [:name :title :description :inputSchema :outputSchema :annotations])))) tools))) +(defn- pad-left + [^String s width] + (if (>= (count s) width) + s + (str (apply str (repeat (- width (count s)) \0)) s))) + +(defn tools-hash + "Return a stable hash of the tool list visible to `token-scopes`, formatted as + an 8-character unsigned hex string. Used by the SSE keepalive loop to detect + manifest changes and emit `notifications/tools/list_changed`. Hashes the JSON + encoding of `[name inputSchema outputSchema]` per tool, sorted by name, so the + result is determined purely by the wire-visible schema bytes — no reliance on + Clojure's `hash` of map values (which can be unstable for non-data leaves + like functions, and is order-sensitive for some collection types)." + [token-scopes] + (-> (->> (list-tools token-scopes) + (map (juxt :name :inputSchema :outputSchema)) + (sort-by first) + json/encode + hash) + (Integer/toUnsignedString 16) + (pad-left 8))) + (defn- build-tool-index "Build name->tool lookup from manifest tools." [tools] diff --git a/test/metabase/mcp/api_test.clj b/test/metabase/mcp/api_test.clj index 25d455e986b8..0f7357f6b287 100644 --- a/test/metabase/mcp/api_test.clj +++ b/test/metabase/mcp/api_test.clj @@ -177,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 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))))) From 522257eef9dce2b7eddcc95ddeff60c8eaf262a9 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 26 May 2026 23:17:48 +0300 Subject: [PATCH 59/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Stop=20dropp?= =?UTF-8?q?ing=20old=20datasets=20from=20snowflake."=20(#74672)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent snowflake tests from deleting old data and failing build. Manual backport of https://github.com/metabase/metabase/pull/74663 Co-authored-by: Phil Hagelberg --- .github/workflows/drivers.yml | 7 ++++--- .../snowflake/test/metabase/test/data/snowflake.clj | 13 +++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) 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/modules/drivers/snowflake/test/metabase/test/data/snowflake.clj b/modules/drivers/snowflake/test/metabase/test/data/snowflake.clj index 3f2de5676f9f..5a49ec579995 100644 --- a/modules/drivers/snowflake/test/metabase/test/data/snowflake.clj +++ b/modules/drivers/snowflake/test/metabase/test/data/snowflake.clj @@ -89,7 +89,7 @@ (defmethod sql.tx/create-db-sql :snowflake [driver dbdef] (let [db (sql.tx/qualify-and-quote driver (qualified-db-name dbdef))] - (format "DROP DATABASE IF EXISTS %s; CREATE DATABASE %s;" db db))) + (format "CREATE DATABASE IF NOT EXISTS %s;" db))) (defn- no-db-connection-spec "Connection spec for connecting to our Snowflake instance without specifying a DB." @@ -183,6 +183,7 @@ (defonce ^:private deleted-old-test-data? (atom false)) +#_{:clj-kondo/ignore [:unused-private-var]} (defn- delete-old-test-data-if-needed! "Call [[delete-old-test-data!]], only if we haven't done so already." [] @@ -204,7 +205,15 @@ ;; qualify the DB name with the unique prefix (let [db-def (assoc db-def :database-name (qualified-db-name db-def))] ;; clean up any old test data (datasets and isolation schemas) - (delete-old-test-data-if-needed!) + ;; disabling this temporarily as it has caused very difficult-to-debug failures + ;; in CI. even tho the datasets *have* been accessed recently, they are still + ;; being deleted and reinserted, with race conditions that cause some data to be + ;; inserted three times. this does mean that if datasets change, old versions + ;; will not be cleaned up automatically and will need to be manually GCed. + ;; local testing shows that identifying old datasets works correctly, but + ;; sometimes randomly in CI it seems to decide that datasets are old and + ;; deletes them even tho they are not old. + ;; (delete-old-test-data-if-needed!) ;; Snowflake by default uses America/Los_Angeles timezone. See https://docs.snowflake.com/en/sql-reference/parameters#timezone. ;; We expect UTC in tests. Hence fixing [[metabase.query-processor.timezone/database-timezone-id]] (PR #36413) ;; produced lot of failures. Following expression addresses that, setting timezone for the test user. From fb0577a07448842423acbe172d8de75576cff9ab Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Tue, 26 May 2026 23:59:04 +0300 Subject: [PATCH 60/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"[Metabot]=20?= =?UTF-8?q?Show=20message=20feedback=20UI=20in=20OSS"=20(#74593)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Metabot] Show message feedback UI in OSS (#74555) * show feedback buttons in self-hosted instances * clean up ishosted flag in setup fn * clean up Co-authored-by: Sloan Sparger --- .../ConversationDetailPage.tsx | 1 - .../components/MetabotChat/MetabotChat.tsx | 4 --- .../MetabotChat/MetabotChatMessage.tsx | 30 +++++-------------- .../MetabotChatMessage.unit.spec.tsx | 2 -- .../metabot/tests/feedback.unit.spec.ts | 23 +++----------- frontend/src/metabase/metabot/tests/utils.tsx | 2 -- 6 files changed, 11 insertions(+), 51 deletions(-) diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationDetailPage/ConversationDetailPage.tsx b/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationDetailPage/ConversationDetailPage.tsx index 75f97c145570..9e539e462188 100644 --- a/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationDetailPage/ConversationDetailPage.tsx +++ b/enterprise/frontend/src/metabase-enterprise/audit_app/metabot-analytics/components/ConversationDetailPage/ConversationDetailPage.tsx @@ -215,7 +215,6 @@ function FeedbackCard({ readonly hideActions getCopyText={noopGetCopyText} - showFeedbackButtons={false} submittedFeedback={undefined} bg="background-secondary" p="md" diff --git a/frontend/src/metabase/metabot/components/MetabotChat/MetabotChat.tsx b/frontend/src/metabase/metabot/components/MetabotChat/MetabotChat.tsx index 12f0753a5887..da721f337dd4 100644 --- a/frontend/src/metabase/metabot/components/MetabotChat/MetabotChat.tsx +++ b/frontend/src/metabase/metabot/components/MetabotChat/MetabotChat.tsx @@ -9,8 +9,6 @@ import { useSetting } from "metabase/common/hooks"; import { AIProviderConfigurationModal } from "metabase/metabot/components/AIProviderConfigurationModal"; import { AIProviderConfigurationNotice } from "metabase/metabot/components/AIProviderConfigurationNotice"; import { MetabotResetLongChatButton } from "metabase/metabot/components/MetabotChat/MetabotResetLongChatButton"; -import { useSelector } from "metabase/redux"; -import { getIsHosted } from "metabase/setup/selectors"; import { ActionIcon, Box, @@ -53,7 +51,6 @@ export const MetabotChat = ({ }: { config?: MetabotConfig; }) => { - const isHosted = useSelector(getIsHosted); const [ isAiProviderConfigurationModalOpen, { @@ -194,7 +191,6 @@ export const MetabotChat = ({ } isDoingScience={metabot.isDoingScience} debug={metabot.debugMode} - showFeedbackButtons={isHosted} /> {/* loading */} {metabot.isDoingScience && ( diff --git a/frontend/src/metabase/metabot/components/MetabotChat/MetabotChatMessage.tsx b/frontend/src/metabase/metabot/components/MetabotChat/MetabotChatMessage.tsx index 707db23c4bb7..0a012e2a9670 100644 --- a/frontend/src/metabase/metabot/components/MetabotChat/MetabotChatMessage.tsx +++ b/frontend/src/metabase/metabot/components/MetabotChat/MetabotChatMessage.tsx @@ -172,7 +172,6 @@ interface AgentMessageProps extends Omit { readonly: boolean; onRetry?: (messageId: string) => void; getCopyText: () => string; - showFeedbackButtons: boolean; setFeedbackMessage?: (data: { messageId: string; positive: boolean }) => void; submittedFeedback: "positive" | "negative" | undefined; onInternalLinkClick?: (link: string) => void; @@ -185,7 +184,6 @@ export const AgentMessage = ({ readonly, getCopyText, onRetry, - showFeedbackButtons, setFeedbackMessage, submittedFeedback, onInternalLinkClick, @@ -193,11 +191,7 @@ export const AgentMessage = ({ ...props }: AgentMessageProps) => { const messageId = "externalId" in message ? (message.externalId ?? "") : ""; - const canGiveFeedback = !!( - showFeedbackButtons && - setFeedbackMessage && - messageId - ); + const canGiveFeedback = !!(setFeedbackMessage && messageId); const clipboard = useClipboard({ timeout: 2000 }); return ( @@ -416,7 +410,6 @@ export const Messages = ({ isDoingScience, debug, readonly = false, - showFeedbackButtons = false, onInternalLinkClick, }: { messages: MetabotChatMessage[]; @@ -424,7 +417,6 @@ export const Messages = ({ isDoingScience: boolean; debug: boolean; readonly?: boolean; - showFeedbackButtons?: boolean; onInternalLinkClick?: (navigateToPath: string) => void; }) => { const visibleMessages = useMemo( @@ -474,17 +466,6 @@ export const Messages = ({ [messages], ); - const setFeedbackModal = useCallback( - (data: { messageId: string; positive: boolean } | undefined) => { - if (!showFeedbackButtons) { - return; - } - - setFeedbackState((prev) => ({ ...prev, modal: data })); - }, - [showFeedbackButtons], - ); - return ( <> {visibleMessages.map((message, index) => { @@ -498,8 +479,9 @@ export const Messages = ({ readonly={readonly} onRetry={onRetryMessage} getCopyText={() => getAgentReplyCopyText(message.id)} - showFeedbackButtons={showFeedbackButtons} - setFeedbackMessage={setFeedbackModal} + setFeedbackMessage={(data) => + setFeedbackState((prev) => ({ ...prev, modal: data })) + } submittedFeedback={ "externalId" in message && message.externalId ? feedbackState.submitted[message.externalId] @@ -521,7 +503,9 @@ export const Messages = ({ {feedbackState.modal && ( setFeedbackModal(undefined)} + onClose={() => + setFeedbackState((prev) => ({ ...prev, modal: undefined })) + } onSubmit={submitFeedback} /> )} diff --git a/frontend/src/metabase/metabot/components/MetabotChat/MetabotChatMessage.unit.spec.tsx b/frontend/src/metabase/metabot/components/MetabotChat/MetabotChatMessage.unit.spec.tsx index 1abffaa3bd47..9ea2c67cfe1a 100644 --- a/frontend/src/metabase/metabot/components/MetabotChat/MetabotChatMessage.unit.spec.tsx +++ b/frontend/src/metabase/metabot/components/MetabotChat/MetabotChatMessage.unit.spec.tsx @@ -9,7 +9,6 @@ const setup = (message: MetabotAgentChatMessage) => debug={false} readonly={false} hideActions - showFeedbackButtons={false} setFeedbackMessage={() => {}} submittedFeedback={undefined} getCopyText={() => ""} @@ -96,7 +95,6 @@ describe("AgentMessage", () => { debug readonly={false} hideActions - showFeedbackButtons={false} setFeedbackMessage={() => {}} submittedFeedback={undefined} getCopyText={() => ""} diff --git a/frontend/src/metabase/metabot/tests/feedback.unit.spec.ts b/frontend/src/metabase/metabot/tests/feedback.unit.spec.ts index febb3e31bc90..fd9ed8f458de 100644 --- a/frontend/src/metabase/metabot/tests/feedback.unit.spec.ts +++ b/frontend/src/metabase/metabot/tests/feedback.unit.spec.ts @@ -15,7 +15,7 @@ import { } from "./utils"; const setupWithNegativeFeedback = async () => { - setup({ isHosted: true }); + setup(); const feedbackEndpoint = mockFeedbackEndpoint(); mockAgentEndpoint({ textChunks: whoIsYourFavoriteResponse }); @@ -51,23 +51,8 @@ const submitFeedback = async (modal: HTMLElement) => { }; describe("metabot > feedback", () => { - it("should not show feedback buttons for non-hosted instances", async () => { - setup({ isHosted: false }); - mockAgentEndpoint({ textChunks: whoIsYourFavoriteResponse }); - - await enterChatMessage("Who is your favorite?"); - const lastMessage = (await lastChatMessage())!; - - expect( - within(lastMessage).queryByTestId("metabot-chat-message-thumbs-up"), - ).not.toBeInTheDocument(); - expect( - within(lastMessage).queryByTestId("metabot-chat-message-thumbs-down"), - ).not.toBeInTheDocument(); - }); - - it("should present the user an option to provide feedback for hosted instances", async () => { - setup({ isHosted: true }); + it("should present the user an option to provide feedback", async () => { + setup(); const feedbackEndpoint = mockFeedbackEndpoint(); mockAgentEndpoint({ textChunks: whoIsYourFavoriteResponse }); @@ -136,7 +121,7 @@ describe("metabot > feedback", () => { }); it("should submit positive feedback", async () => { - setup({ isHosted: true }); + setup(); const feedbackEndpoint = mockFeedbackEndpoint(); mockAgentEndpoint({ textChunks: whoIsYourFavoriteResponse }); diff --git a/frontend/src/metabase/metabot/tests/utils.tsx b/frontend/src/metabase/metabot/tests/utils.tsx index 884f36c325c4..6324fb6dc831 100644 --- a/frontend/src/metabase/metabot/tests/utils.tsx +++ b/frontend/src/metabase/metabot/tests/utils.tsx @@ -182,7 +182,6 @@ export function setup( metabotInitialState?: MetabotState; currentUser?: User | null | undefined; promptSuggestions?: { prompt: string }[]; - isHosted?: boolean; storeInitialState?: RenderWithProvidersOptions["storeInitialState"]; customReducers?: RenderWithProvidersOptions["customReducers"]; isConfigured?: boolean; @@ -190,7 +189,6 @@ export function setup( ) { const settings = mockSettings({ "llm-metabot-configured?": options?.isConfigured ?? true, - "is-hosted?": options?.isHosted ?? false, }); setupEnterprisePlugins(); From 888fd6a6e9ca08d9905823e2eb9ea737d6268a9f Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Wed, 27 May 2026 01:00:50 +0300 Subject: [PATCH 61/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Surface=20up?= =?UTF-8?q?stream=20body=20in=20LLM=20provider=20error=20logs=20and=20mess?= =?UTF-8?q?ages"=20(#74793)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Chris Truter fix for the same gap addressed in PR #74356 --- src/metabase/metabot/agent/core.clj | 28 ++- src/metabase/metabot/self/claude.clj | 15 +- src/metabase/metabot/self/core.clj | 186 +++++++++++++-- src/metabase/metabot/self/openai.clj | 15 +- src/metabase/metabot/self/openrouter.clj | 15 +- test/metabase/metabot/self_test.clj | 273 ++++++++++++++++++++++- 6 files changed, 476 insertions(+), 56 deletions(-) diff --git a/src/metabase/metabot/agent/core.clj b/src/metabase/metabot/agent/core.clj index 4a98d1f60fbc..e6f14ea37574 100644 --- a/src/metabase/metabot/agent/core.clj +++ b/src/metabase/metabot/agent/core.clj @@ -623,17 +623,23 @@ result)) (catch Exception e (analytics/inc! :metabase-metabot/agent-errors labels) - (if (:api-error (ex-data e)) - (if (:status (ex-data e)) - (log/errorf "Agent loop API error: %s status=%s provider=%s body=%s" - (ex-message e) - (:status (ex-data e)) - (:provider (ex-data e)) - (pr-str (:body (ex-data e)))) - (log/errorf e "Agent loop API error: %s provider=%s" - (ex-message e) - (:provider (ex-data e)))) - (log/error e "Agent loop error")) + (let [{:keys [api-error status provider body]} (ex-data e) + msg (ex-message e)] + (cond + (and api-error status) + (log/errorf e "Agent loop API error: %s status=%s provider=%s body=%s" + msg status provider (pr-str body)) + + api-error + (log/errorf e "Agent loop API error: %s provider=%s" msg provider) + + ;; ex-message can be nil/blank for exceptions thrown without a message + ;; (e.g. (NullPointerException.)) — skip the colon when there's nothing to say. + (str/blank? msg) + (log/error e "Agent loop error") + + :else + (log/errorf e "Agent loop error: %s" msg))) (rf init (error-part e))) (finally (analytics/observe! :metabase-metabot/agent-duration-ms labels (u/since-ms start-ms))))))))))) diff --git a/src/metabase/metabot/self/claude.clj b/src/metabase/metabot/self/claude.clj index 8ee0ca0e8c9d..29799b6c3218 100644 --- a/src/metabase/metabot/self/claude.clj +++ b/src/metabase/metabot/self/claude.clj @@ -260,9 +260,10 @@ {:type "text" :text suffix}])))) -(defn- anthropic-errors [res] - (let [status (long (:status res 0)) - error-msg (get-in res [:body :error :message])] +(defn- anthropic-error-msg + "Canonical, status-specific Anthropic error message." + [res] + (let [status (long (:status res 0))] (case status 401 (tru "Anthropic API key expired or invalid") 403 (tru "Anthropic API key has insufficient permissions") @@ -271,9 +272,7 @@ 429 (tru "Anthropic API has rate limited us") 500 (tru "Anthropic API is not working but not saying why") 529 (tru "Anthropic API is overloaded and is asking us to wait") - (if error-msg - (tru "Anthropic API error (HTTP {0}): {1}" status error-msg) - (tru "Anthropic API error (HTTP {0})" status))))) + (tru "Anthropic API error (HTTP {0})" status)))) (defn list-models "List available Anthropic models. @@ -295,7 +294,7 @@ models (reverse (sort-by :created_at (:data body)))] {:models (map #(select-keys % [:id :display_name]) models)}) (catch Exception e - (core/rethrow-api-error! "anthropic" anthropic-errors e))))) + (core/rethrow-api-error! "anthropic" anthropic-error-msg e))))) (mu/defn claude-raw "Perform a streaming request to Claude API." @@ -347,7 +346,7 @@ :url "/v1/messages" :request req}))) (catch Exception e - (core/rethrow-api-error! "anthropic" anthropic-errors e)))))) + (core/rethrow-api-error! "anthropic" anthropic-error-msg e)))))) (defn claude "Call Claude API, return AISDK stream" diff --git a/src/metabase/metabot/self/core.clj b/src/metabase/metabot/self/core.clj index 6c620ae4bc2c..80ed47929851 100644 --- a/src/metabase/metabot/self/core.clj +++ b/src/metabase/metabot/self/core.clj @@ -11,7 +11,7 @@ [metabase.util.log :as log] [metabase.util.o11y :refer [with-span]]) (:import - (java.io BufferedReader Closeable) + (java.io BufferedReader Closeable InputStream) (java.util.concurrent Callable Executors ExecutorService))) (set! *warn-on-reflection* true) @@ -501,29 +501,175 @@ (rf result chunk)))))) +(def ^:private max-body-preview-chars + "Cap on the body snippet spliced into provider error messages." + 500) + +(defn- extract-error-message + "First non-blank string under `[:error :message]`, `:error`, `:detail`, or `:message`. + Non-strings and whitespace-only strings fall through to the next key." + [m] + ;; Filter per-lookup so a structured value (e.g. {:error {:code 500}}) never gets + ;; str-coerced into the user-facing exception message — bad shapes fall through to + ;; the next key instead. + (letfn [(s [v] (when (string? v) (not-empty (str/trim v))))] + (or (s (get-in m [:error :message])) + (s (:error m)) + (s (:detail m)) + (s (:message m))))) + +(def ^:private body-slurp-chunk-chars + "Read-chunk size for [[slurp-bounded]]. Keeps the transient buffer small so a tiny error + envelope costs a tiny allocation; only a body that actually reaches the cap pays for it." + 8192) + +(defn- slurp-bounded + "Read at most `limit` chars from `r` as UTF-8, returning nil on read error or empty input. + Grows a `StringBuilder` in [[body-slurp-chunk-chars]] chunks so memory scales with the real + body rather than pre-allocating the worst-case `limit`-sized buffer up front." + [r limit] + (try + (with-open [rdr (io/reader r :encoding "UTF-8")] + (let [buf (char-array body-slurp-chunk-chars) + sb (StringBuilder.)] + (loop [] + (let [remaining (- limit (.length sb))] + (if (<= remaining 0) + (str sb) + (let [n (.read ^java.io.Reader rdr buf 0 (min remaining body-slurp-chunk-chars))] + (if (neg? n) + (when (pos? (.length sb)) (str sb)) + (do (.append sb buf 0 n) + (recur))))))))) + (catch Exception _ nil))) + +(def ^:private max-body-slurp-chars + "Cap on how many chars we read off an upstream InputStream body when decoding for error surfacing." + ;; Large enough to cover any realistic JSON error envelope; small enough to bound the + ;; pathological multi-MB case (e.g. a stuck stream from a misbehaving upstream). + 1000000) + +(def ^:private max-body-log-chars + "Cap on the body `pr-str` spliced into warn/error log lines. Larger than the user-facing + preview cap since operators want more context, but still bounded so a multi-MB body — or + a near-cap slurped stream — can't flood the logs. The full body always survives in `ex-data`." + 2000) + +(defn- decode-bounded-body + "Decode a clj-http response map's `:body` for error surfacing. + Returns the response map with `:body` set to the decoded value, or — on parse failure — + to the raw bounded string so [[body-preview]] still has something to surface." + [res] + ;; Bound-slurp InputStream bodies first so a giant upstream payload doesn't get pulled + ;; fully into memory. On parse failure (e.g. the cap truncated us mid-envelope) we keep + ;; the bounded string in :body rather than nil-ing it out. + (let [bounded (cond-> res + (instance? InputStream (:body res)) + (update :body #(or (slurp-bounded % max-body-slurp-chars) "")))] + (try + (json/decode-body bounded) + (catch Exception _ bounded)))) + +(defn- truncate-to + "Cap `s` at `limit` with a trailing ellipsis when it overflows." + [s limit] + (if (<= (count s) limit) + s + (str (subs s 0 limit) "…"))) + +(defn- truncate-to-preview-limit + "Cap `s` at [[max-body-preview-chars]] with a trailing ellipsis when it overflows." + [s] + (truncate-to s max-body-preview-chars)) + +(defn- body-for-log + "Bounded `pr-str` of a coerced body for warn/error log lines, capped at [[max-body-log-chars]]." + [body] + (truncate-to (pr-str body) max-body-log-chars)) + +(defn- body-preview + "Short snippet of an upstream response body for the user-facing exception message. + Non-empty maps/arrays without a recognised human-readable field fall back to `pr-str` and emit a warn. + Nil/empty bodies return nil." + [body] + (let [extracted (cond + (nil? body) nil + (string? body) body + (map? body) (extract-error-message body) + (sequential? body) (let [head (first body)] + (cond + (map? head) (extract-error-message head) + (string? head) head + :else nil)) + :else nil) + ;; Surface *some* context to the user even for unrecognised shapes — the warn + ;; signals operators to add the new envelope shape to [[extract-error-message]]. + ;; Truncate once: the warn line shouldn't carry a multi-MB pr-str any more than + ;; the user-facing exception message should. + s (or extracted + (when (and (or (map? body) (sequential? body)) (seq body)) + (let [capped (truncate-to-preview-limit (pr-str body))] + (log/warnf "body-preview: unrecognised error body shape; pr-str=%s" capped) + capped)))] + (some-> s str/trim not-empty truncate-to-preview-limit))) + +(def ^:private auth-error-statuses + "Statuses whose upstream body may carry provider-side auth/account detail + (raw API keys, org/account names, tenant IDs). The full body still hits the + warn log; we just don't splice it into the message the caller sees." + #{401 403}) + (defn rethrow-api-error! "Rethrow a provider HTTP exception with a translated, user-facing message. - - `res->message` receives the decoded response map and must return the message - to surface to the client. If the exception already carries `:api-error true` - in its ex-data (e.g. a missing-API-key error from [[resolve-auth]]) it is - rethrown as-is so the original message is preserved." - [provider res->message e] + `res->message` receives the decoded response map and returns the provider-specific message. + A body preview is appended to the message except on 401/403 (see [[auth-error-statuses]]), + where the body may carry sensitive auth/account detail; the full body is still logged. + ex-data is an explicit allow-list of `:status`, `:reason-phrase`, `:headers`, `:body`, plus provider tags. + Exceptions already tagged `:api-error true` are rethrown unchanged." + [provider res->message ^Throwable e] (let [data (ex-data e)] (cond - (:api-error data) (throw e) - (:body data) (let [res (json/decode-body data)] - (throw (ex-info (res->message res) - (assoc res - :api-error true - :provider provider - :error-code :provider-api-error) - e))) - :else (throw (ex-info (tru "{0} API request failed: {1}" provider (ex-message e)) - {:api-error true - :provider provider - :error-code :provider-request-failed} - e))))) + (:api-error data) + (throw e) + + (:body data) + (let [res (decode-bounded-body data) + base (res->message res) + preview (when-not (contains? auth-error-statuses (:status res)) + (body-preview (:body res))) + msg (cond-> base + preview (str " — " preview))] + ;; warnf (not warn) so the body renders into the message string, not as MDC. + ;; body-for-log caps the pr-str so a near-cap slurped stream can't flood the logs; + ;; the full body still survives in ex-data below. + (log/warnf "Provider API request failed: provider=%s status=%s body=%s" + provider (:status res) (body-for-log (:body res))) + ;; Allow-list explicitly — clj-http responses carry :http-client (a Closeable), + ;; :trace-redirects, :orig-content-encoding, etc., none of which should propagate downstream. + ;; :headers is included so the retry path in metabase.metabot.self/parse-retry-after-header + ;; can still honor Retry-After on 429/529 responses. + (throw (ex-info msg + (merge (select-keys res [:status :reason-phrase :headers :body]) + {:api-error true + :provider provider + :error-code :provider-api-error}) + e))) + + :else + (let [exception-class (some-> e .getClass .getName) + msg (ex-message e)] + (log/warnf "Provider API request failed (no response): provider=%s class=%s message=%s" + provider exception-class msg) + ;; ex-message can be nil/blank for exceptions thrown without one (e.g. (RuntimeException.)) + ;; — skip the colon when the cause has nothing to say. + (throw (ex-info (if (str/blank? msg) + (tru "{0} API request failed" provider) + (tru "{0} API request failed: {1}" provider msg)) + {:api-error true + :provider provider + :error-code :provider-request-failed + :exception-class exception-class} + e)))))) (defn missing-api-key-ex "Create a standardized missing-API-key exception for provider adapters." diff --git a/src/metabase/metabot/self/openai.clj b/src/metabase/metabot/self/openai.clj index 77cbb197dd73..7667e08228da 100644 --- a/src/metabase/metabot/self/openai.clj +++ b/src/metabase/metabot/self/openai.clj @@ -160,18 +160,17 @@ :description doc :parameters (mjs/transform params {:additionalProperties false})})) -(defn- openai-errors [res] - (let [status (long (:status res 0)) - error-msg (get-in res [:body :error :message])] +(defn- openai-error-msg + "Canonical, status-specific OpenAI error message." + [res] + (let [status (long (:status res 0))] (case status 401 (tru "OpenAI API key expired or invalid") 403 (tru "OpenAI API key has insufficient permissions") 404 (tru "OpenAI API endpoint or model listing is unavailable") 429 (tru "OpenAI API has rate limited us") 500 (tru "OpenAI API is not working but not saying why") - (if error-msg - (tru "OpenAI API error (HTTP {0}): {1}" status error-msg) - (tru "OpenAI API error (HTTP {0})" status))))) + (tru "OpenAI API error (HTTP {0})" status)))) (defn list-models "List available OpenAI models. @@ -195,7 +194,7 @@ :display_name (:id model)}) (reverse (sort-by :created (get-in res [:body :data]))))}) (catch Exception e - (core/rethrow-api-error! "openai" openai-errors e))))) + (core/rethrow-api-error! "openai" openai-error-msg e))))) (mu/defn openai-raw "Perform a streaming request to OpenAI Responses API." @@ -239,7 +238,7 @@ :url "/v1/responses" :request req}))) (catch Exception e - (core/rethrow-api-error! "openai" openai-errors e))))) + (core/rethrow-api-error! "openai" openai-error-msg e))))) (defn openai "Call OpenAI API, return AISDK stream." diff --git a/src/metabase/metabot/self/openrouter.clj b/src/metabase/metabot/self/openrouter.clj index cc0d0f415315..767108c9fe45 100644 --- a/src/metabase/metabot/self/openrouter.clj +++ b/src/metabase/metabot/self/openrouter.clj @@ -92,9 +92,10 @@ :description doc :parameters (mjs/transform params {:additionalProperties false})}})) -(defn- openrouter-errors [res] - (let [status (long (:status res 0)) - error-msg (get-in res [:body :error :message])] +(defn- openrouter-error-msg + "Canonical, status-specific OpenRouter error message." + [res] + (let [status (long (:status res 0))] (case status 401 (tru "OpenRouter API key expired or invalid") 402 (tru "OpenRouter has insufficient credits") @@ -104,9 +105,7 @@ 500 (tru "OpenRouter returned an internal server error") 502 (tru "OpenRouter upstream provider returned an error") 503 (tru "OpenRouter service is unavailable") - (if error-msg - (tru "OpenRouter API error (HTTP {0}): {1}" status error-msg) - (tru "OpenRouter API error (HTTP {0})" status))))) + (tru "OpenRouter API error (HTTP {0})" status)))) (defn list-models "List available OpenRouter models. @@ -132,7 +131,7 @@ :display_name (or (:name model) (:id model))}) (reverse (sort-by :created (get-in res [:body :data]))))}) (catch Exception e - (core/rethrow-api-error! "openrouter" openrouter-errors e))))) + (core/rethrow-api-error! "openrouter" openrouter-error-msg e))))) ;;; Streaming response → AISDK v5 chunks @@ -305,7 +304,7 @@ :url "/v1/chat/completions" :request req}))) (catch Exception e - (core/rethrow-api-error! "openrouter" openrouter-errors e)))))) + (core/rethrow-api-error! "openrouter" openrouter-error-msg e)))))) (defn openrouter "Call OpenRouter Chat Completions API, return AISDK stream." diff --git a/test/metabase/metabot/self_test.clj b/test/metabase/metabot/self_test.clj index 8fe1e79ea652..3090bb6ae43a 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" @@ -878,3 +880,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"))))) From 5e06fbcbc3d65f819eef5433026ab62c9b8a9b1f Mon Sep 17 00:00:00 2001 From: Cam Saul <1455846+camsaul@users.noreply.github.com> Date: Tue, 26 May 2026 15:33:01 -0700 Subject: [PATCH 62/63] Enable cljfmt `remove-blank-lines-in-forms?` [61] (#74803) --- .cljfmt.edn | 55 ++++- bb.edn | 5 +- bin/build/src/build.clj | 1 - bin/build/src/metabuild_common/core.clj | 7 - bin/build/test/i18n/autofix_test.clj | 5 - .../test/lint_migrations_file_test.clj | 8 - bin/release-list/src/release_list/main.clj | 1 - .../metabase_enterprise/action_v2/coerce.clj | 1 - .../action_v2/execute_form.clj | 3 - .../action_v2/models/undo.clj | 2 - .../advanced_config/file/api_keys.clj | 2 - .../models/permissions/block_permissions.clj | 1 - .../audit_app/analytics_dev.clj | 2 - .../audit_app/api/analytics_dev.clj | 9 - .../content_translation/routes.clj | 1 - .../metabase_enterprise/dependencies/core.clj | 1 - .../dependencies/metadata_provider.clj | 1 - .../dependencies/metadata_update.clj | 1 - .../src/metabase_enterprise/email/api.clj | 2 - .../src/metabase_enterprise/gsheets/api.clj | 2 - .../metabase_enterprise/gsheets/settings.clj | 3 - .../metabot/tools/transforms/write.clj | 3 - .../permission_debug/impl.clj | 1 - .../metabase_enterprise/remote_sync/core.clj | 1 - .../metabase_enterprise/remote_sync/impl.clj | 1 - .../metabase_enterprise/remote_sync/init.clj | 1 - .../remote_sync/settings.clj | 1 - .../remote_sync/source/git.clj | 9 - .../replacement/runner.clj | 8 - .../semantic_search/core.clj | 2 - .../semantic_search/embedding.clj | 2 - .../semantic_search/gate.clj | 5 - .../semantic_search/index.clj | 7 - .../semantic_search/indexer.clj | 18 -- .../semantic_search/pgvector_api.clj | 3 - .../sso/integrations/saml.clj | 2 - .../sso/providers/oidc.clj | 1 - .../src/metabase_enterprise/stale/api.clj | 1 - .../support_access_grants/core.clj | 2 - .../transforms_python/base.clj | 6 - .../action_v2/api_test.clj | 54 ----- .../action_v2/coerce_test.clj | 11 - .../action_v2/models/undo_test.clj | 51 ---- .../action_v2/validation_test.clj | 24 -- .../advanced_config/api/logs_test.clj | 1 - .../advanced_config/file/api_keys_test.clj | 1 - .../advanced_config/file/settings_test.clj | 1 - .../advanced_config/file/users_test.clj | 1 - .../api/application_test.clj | 8 - .../advanced_permissions/api/channel_test.clj | 4 - .../api/group_manager_test.clj | 24 -- .../api/monitoring_test.clj | 5 - .../advanced_permissions/api/setting_test.clj | 34 --- .../api/subscription_test.clj | 2 - .../advanced_permissions/common_test.clj | 48 ---- .../models/field_values_test.clj | 2 - .../application_permissions_test.clj | 6 - .../permissions/block_permissions_test.clj | 9 - .../models/permissions/group_manager_test.clj | 5 - .../middleware/permissions_test.clj | 18 -- .../metabase_enterprise/api/session_test.clj | 6 - .../audit_app/analytics_dev_test.clj | 6 - .../audit_app/api/user_test.clj | 1 - .../audit_app/audit_test.clj | 14 -- .../audit_app/pages/common_test.clj | 4 - .../audit_app/permissions_test.clj | 3 - .../metabase_enterprise/cache/cache_test.clj | 9 - .../cache/strategies_test.clj | 5 - .../cache/task/refresh_cache_configs_test.clj | 35 --- .../checker/format/serdes_schema_test.clj | 1 - .../cloud_proxy/api_test.clj | 3 - .../content_translation/dictionary_test.clj | 6 - .../api/collection_test.clj | 8 - .../api/moderation_review_test.clj | 1 - .../parameter_test.clj | 1 - .../data_complexity_score/complexity_test.clj | 13 -- .../task/complexity_score_trimmer_test.clj | 4 - .../data_studio/api/query_metadata_test.clj | 3 - .../data_studio/permissions/query_test.clj | 1 - .../database_replication/api_test.clj | 7 - .../database_routing/e2e_test.clj | 1 - .../database_routing/embedding_test.clj | 1 - .../dependencies/api_test.clj | 2 - .../dependencies/calculation_test.clj | 2 - .../dependencies/metadata_provider_test.clj | 1 - .../dependencies/metadata_update_test.clj | 12 - .../models/analysis_finding_error_test.clj | 1 - .../dependencies/models/dependency_test.clj | 4 - .../dependencies/native_validation_test.clj | 1 - .../dependencies/task/backfill_test.clj | 6 - .../metabase_enterprise/email/api_test.clj | 1 - .../embedding_hub/api_test.clj | 12 - .../metabase_enterprise/gsheets/api_test.clj | 2 - .../gsheets/settings_test.clj | 2 - .../impersonation/api_test.clj | 7 - .../impersonation/driver_test.clj | 12 - .../impersonation/util_test.clj | 2 - .../internal_stats/core_test.clj | 3 - .../internal_stats/metabot_test.clj | 29 --- .../metabot/tools/semantic_search_test.clj | 7 - .../metabot_analytics/api_test.clj | 2 - .../permission_debug/api_test.clj | 8 - .../remote_sync/dashboard_api_test.clj | 3 - .../remote_sync/events_test.clj | 11 - .../remote_sync/impl_test.clj | 6 - .../models/remote_sync_object_test.clj | 1 - .../models/remote_sync_task_test.clj | 5 - .../remote_sync/permissions_test.clj | 11 - .../remote_sync/settings_test.clj | 2 - .../remote_sync/source/git_test.clj | 27 --- .../remote_sync/source/ingestable_test.clj | 26 --- .../remote_sync/source_test.clj | 12 - .../remote_sync/spec_test.clj | 30 --- .../remote_sync/task/table_cleanup_test.clj | 4 - .../remote_sync/transforms_test.clj | 1 - .../replacement/api_test.clj | 15 -- .../replacement/field_refs_test.clj | 1 - .../replacement/runner_test.clj | 26 --- .../sandbox/api/card_test.clj | 5 - .../sandbox/api/dashboard_test.clj | 3 - .../sandbox/api/dataset_test.clj | 10 - .../sandbox/api/download_test.clj | 2 - .../sandbox/api/field_test.clj | 5 - .../sandbox/api/gtap_test.clj | 11 - .../sandbox/api/table_test.clj | 4 - .../sandbox/api/user_test.clj | 8 - .../sandbox/api/util_test.clj | 8 - .../models/params/chain_filter_test.clj | 3 - .../models/params/field_values_test.clj | 12 - .../sandbox/models/sandbox_test.clj | 2 - .../sandbox/notification_test.clj | 1 - .../sandbox/pulse_test.clj | 9 - .../middleware/sandboxing_test.clj | 10 - .../metabase_enterprise/scim/api_test.clj | 3 - .../metabase_enterprise/scim/v2/api_test.clj | 35 --- .../search/scoring_test.clj | 3 - .../security_center/notification_test.clj | 1 - .../security_center/settings_test.clj | 8 - .../semantic_search/api_test.clj | 12 - .../semantic_search/core_test.clj | 2 - .../semantic_search/db_test.clj | 4 - .../semantic_search/dlq_test.clj | 33 --- .../semantic_search/embedding_test.clj | 18 -- .../semantic_search/gate_test.clj | 28 --- .../semantic_search/index_metadata_test.clj | 3 - .../semantic_search/index_test.clj | 33 --- .../semantic_search/indexer_test.clj | 62 ----- .../semantic_search/pgvector_api_test.clj | 5 - .../semantic_search/query_test.clj | 10 - .../semantic_search/repair_test.clj | 5 - .../semantic_search/scoring_test.clj | 2 - .../task/index_cleanup_test.clj | 10 - .../serialization/api_test.clj | 26 --- .../serialization/cmd_test.clj | 3 - .../serialization/v2/backfill_ids_test.clj | 5 - .../serialization/v2/e2e_test.clj | 56 ----- .../serialization/v2/extract_test.clj | 156 ------------- .../serialization/v2/ingest_test.clj | 6 - .../serialization/v2/load_test.clj | 123 ---------- .../serialization/v2/models_test.clj | 7 - .../serialization/v2/storage_test.clj | 15 -- .../native_query_snippet/permissions_test.clj | 1 - .../metabase_enterprise/sso/api/oidc_test.clj | 8 - .../metabase_enterprise/sso/api/saml_test.clj | 1 - .../sso/integrations/jwt_test.clj | 20 -- .../sso/integrations/ldap_test.clj | 5 - .../sso/integrations/saml_test.clj | 4 - .../sso/integrations/sso_utils_test.clj | 1 - .../sso/integrations/token_utils_test.clj | 8 - .../metabase_enterprise/stale/api_test.clj | 5 - .../support_access_grants/provider_test.clj | 1 - .../metabase_enterprise/tenants/api_test.clj | 6 - .../tenants/models_test.clj | 1 - .../tenants/permissions_api_test.clj | 1 - .../tenants/user_api_test.clj | 7 - .../transforms/core_test.clj | 7 - .../transforms/incremental_test.clj | 17 -- .../transforms/ordering_test.clj | 2 - .../transforms/util_test.clj | 1 - .../transforms_inspector/e2e_test.clj | 5 - .../transforms_inspector/lens/core_test.clj | 1 - .../transforms_python/drivers_test.clj | 27 --- .../transforms_python/e2e_test.clj | 1 - .../transforms_python/execute_test.clj | 6 - .../models/python_library_test.clj | 5 - .../transforms_python/python_runner_test.clj | 10 - .../transforms_python/s3_test.clj | 8 - .../transforms_python/transforms_api_test.clj | 21 -- .../upload_management/api_test.clj | 5 - .../driver/athena/hive_parser_test.clj | 1 - .../metabase/driver/bigquery_cloud_sdk.clj | 17 -- .../query_processor_test.clj | 4 - .../driver/bigquery_cloud_sdk_test.clj | 7 - .../src/metabase/driver/clickhouse_qp.clj | 1 - .../driver/clickhouse_introspection_test.clj | 1 - .../driver/clickhouse_substitution_test.clj | 1 - .../test/metabase/driver/druid_jdbc_test.clj | 6 - .../src/metabase/driver/druid/client.clj | 1 - .../metabase/driver/druid/client_test.clj | 2 - .../driver/druid/query_processor_test.clj | 10 - .../test/metabase/driver/druid/sync_test.clj | 1 - .../mongo/src/metabase/driver/mongo.clj | 1 - .../metabase/driver/mongo/query_processor.clj | 3 - .../driver/mongo/query_processor_test.clj | 1 - .../mongo/test/metabase/driver/mongo_test.clj | 18 -- .../test/metabase/driver/oracle_test.clj | 4 - .../redshift/src/metabase/driver/redshift.clj | 3 - .../test/metabase/driver/redshift_test.clj | 8 - .../src/metabase/driver/snowflake.clj | 3 - .../test/metabase/driver/snowflake_test.clj | 19 -- .../test/metabase/driver/sqlite_test.clj | 1 - .../test/metabase/driver/sqlserver_test.clj | 4 - .../src/metabase/driver/starburst.clj | 1 - .../test/metabase/driver/starburst_test.clj | 2 - .../activity_feed/models/recent_views.clj | 3 - src/metabase/analytics/core.clj | 15 -- src/metabase/analytics/prometheus.clj | 6 - src/metabase/analyze/fingerprint/insights.clj | 1 - src/metabase/api/open_api.clj | 13 -- src/metabase/app_db/connection.clj | 1 - src/metabase/app_db/core.clj | 11 - src/metabase/app_db/custom_migrations.clj | 2 - .../app_db/custom_migrations/util.clj | 1 - src/metabase/app_db/data_source.clj | 1 - src/metabase/app_db/liquibase.clj | 1 - src/metabase/app_db/setup.clj | 1 - src/metabase/channel/api/slack.clj | 2 - .../task/refresh_slack_channel_user_cache.clj | 1 - .../models/cloud_migration.clj | 6 - .../collections/models/collection.clj | 16 -- src/metabase/collections/schema.clj | 4 - src/metabase/collections_rest/api.clj | 3 - src/metabase/comments/api.clj | 12 - .../comments/models/comment_reaction.clj | 1 - src/metabase/dashboards/models/dashboard.clj | 2 - .../dashboards/models/dashboard_card.clj | 4 - .../dashboards/models/dashboard_tab.clj | 1 - src/metabase/dashboards_rest/api.clj | 2 - src/metabase/documents/api/document.clj | 1 - src/metabase/driver.clj | 83 ------- .../driver/common/parameters/dates.clj | 5 - src/metabase/driver/h2.clj | 2 - src/metabase/driver/mysql.clj | 4 - src/metabase/driver/postgres.clj | 3 - src/metabase/driver/sql/query_processor.clj | 1 - src/metabase/driver/sql_jdbc/sync.clj | 3 - .../sql_jdbc/sync/describe_database.clj | 3 - src/metabase/driver/util.clj | 2 - src/metabase/events/impl.clj | 3 - src/metabase/interestingness/chart/stats.clj | 2 - src/metabase/legacy_mbql/schema.cljc | 11 - src/metabase/lib/aggregation.cljc | 1 - src/metabase/lib/core.cljc | 1 - src/metabase/lib/display_name.cljc | 7 - src/metabase/lib/fe_util.cljc | 5 - src/metabase/lib/field.cljc | 1 - src/metabase/lib/field/util.cljc | 2 - src/metabase/lib/filter/desugar/jvm.clj | 1 - src/metabase/lib/filter/update.cljc | 1 - src/metabase/lib/native.cljc | 1 - src/metabase/lib/schema/constraints.cljc | 1 - .../lib/schema/expression/conditional.cljc | 1 - .../lib/schema/expression/temporal.cljc | 2 - .../lib/schema/middleware_options.cljc | 6 - src/metabase/lib/schema/parameter.cljc | 7 - src/metabase/lib/types/constants.cljc | 3 - src/metabase/lib/util.cljc | 1 - src/metabase/lib_metric/core.cljc | 1 - src/metabase/llm/context.clj | 2 - src/metabase/logger/core.clj | 2 - .../metabot/agent/markdown_link_buffer.clj | 1 - src/metabase/metabot/api/document.clj | 1 - src/metabase/metabot/api/entity_analysis.clj | 1 - src/metabase/metabot/context.clj | 3 - src/metabase/metabot/self/core.clj | 1 - src/metabase/metabot/self/openrouter.clj | 1 - .../metabot/tools/autogen_dashboard.clj | 2 - src/metabase/metabot/tools/charts.clj | 3 - src/metabase/metabot/tools/charts/create.clj | 3 - src/metabase/metabot/tools/charts/edit.clj | 2 - src/metabase/metabot/tools/resources.clj | 2 - src/metabase/metabot/tools/sql/edit.clj | 2 - src/metabase/metabot/tools/sql/replace.clj | 1 - .../metabot/tools/transforms/write.clj | 3 - .../task/persist_refresh.clj | 1 - src/metabase/models/json_migration.clj | 1 - src/metabase/models/serialization.clj | 1 - src/metabase/models/util/spec_update.clj | 6 - .../native_query_snippet/permissions.clj | 2 - .../notification/api/notification.clj | 1 - src/metabase/notification/models.clj | 1 - src/metabase/notification/payload/execute.clj | 1 - .../notification/payload/impl/card.clj | 1 - .../notification/payload/temp_storage.clj | 10 - src/metabase/notification/seed.clj | 6 - src/metabase/notification/send.clj | 1 - src/metabase/notification/task/send.clj | 1 - src/metabase/parameters/chain_filter.clj | 1 - .../permissions/models/collection/graph.clj | 1 - src/metabase/permissions/user.clj | 3 - src/metabase/plugins/dependencies.clj | 1 - src/metabase/plugins/jdbc_proxy.clj | 1 - src/metabase/premium_features/core.clj | 2 - src/metabase/premium_features/token_check.clj | 2 - src/metabase/pulse/api/pulse.clj | 3 - src/metabase/pulse/models/pulse.clj | 1 - ...ards_notification_deleted_on_card_save.clj | 1 - src/metabase/queries/models/card.clj | 6 - src/metabase/queries/models/query.clj | 1 - src/metabase/queries_rest/api/card.clj | 3 - src/metabase/query_permissions/impl.clj | 4 - src/metabase/query_processor/core.clj | 1 - .../middleware/add_implicit_joins.clj | 1 - .../query_processor/middleware/cache/impl.clj | 1 - .../middleware/permissions.clj | 3 - .../middleware/resolve_joins.clj | 1 - .../query_processor/parameters/dates.clj | 5 - src/metabase/query_processor/store.clj | 1 - .../query_processor/streaming/csv.clj | 2 - src/metabase/revisions/impl/dashboard.clj | 1 - src/metabase/search/api.clj | 1 - src/metabase/search/appdb/core.clj | 2 - src/metabase/search/appdb/index.clj | 1 - src/metabase/search/config.clj | 1 - src/metabase/search/core.clj | 5 - src/metabase/search/ingestion.clj | 1 - src/metabase/search/spec.clj | 1 - src/metabase/secrets/models/secret.clj | 1 - src/metabase/server/middleware/security.clj | 1 - src/metabase/server/routes.clj | 3 - src/metabase/settings/models/setting.clj | 24 -- src/metabase/sso/common.clj | 1 - src/metabase/sso/providers/oidc.clj | 5 - src/metabase/sso/providers/slack_connect.clj | 1 - src/metabase/sync/sync_metadata/fks.clj | 1 - src/metabase/sync/sync_metadata/tables.clj | 5 - src/metabase/task/impl.clj | 2 - src/metabase/testing_api/api.clj | 1 - src/metabase/transforms/crud.clj | 1 - src/metabase/transforms/jobs.clj | 2 - src/metabase/transforms/models/transform.clj | 3 - .../transforms/models/transform_job.clj | 3 - src/metabase/transforms_base/query.clj | 9 - .../transforms_rest/api/transform.clj | 1 - src/metabase/upload/impl.clj | 15 -- src/metabase/util.cljc | 3 - src/metabase/util/compress.clj | 1 - src/metabase/util/date_2.clj | 1 - src/metabase/util/devtools.cljc | 3 - src/metabase/util/encryption.clj | 1 - src/metabase/util/formatting/constants.cljc | 4 - src/metabase/util/log/capture.cljc | 4 - src/metabase/util/malli.cljc | 6 +- src/metabase/util/malli/registry.cljc | 6 +- src/metabase/util/performance.cljc | 4 - src/metabase/util/queue.clj | 3 - src/metabase/util/time/impl.clj | 1 - src/metabase/util/time/impl.cljs | 3 +- .../warehouse_schema/models/table.clj | 3 - .../warehouse_schema_rest/api/field.clj | 1 - src/metabase/warehouses_rest/api.clj | 1 - .../xrays/automagic_dashboards/comparison.clj | 1 - test/metabase/actions/actions_test.clj | 8 - test/metabase/actions/scope_test.clj | 10 - test/metabase/actions/settings_test.clj | 6 - test/metabase/actions_rest/api_test.clj | 15 -- test/metabase/activity_feed/api_test.clj | 1 - .../models/recent_views_test.clj | 7 - test/metabase/agent_api/api_test.clj | 25 -- .../analytics/llm_token_usage_test.clj | 8 - test/metabase/analytics/prometheus_test.clj | 9 - test/metabase/analytics/quartz_test.clj | 5 - test/metabase/analytics/snowplow_test.clj | 9 - test/metabase/analytics/stats_test.clj | 14 -- test/metabase/api/common_test.clj | 6 - test/metabase/api/downloads_exports_test.clj | 5 - .../api/macros/defendpoint/open_api_test.clj | 2 - .../defendpoint/tools_manifest_test.clj | 2 - test/metabase/api/routes/common_test.clj | 3 - test/metabase/api_keys/api_test.clj | 1 - test/metabase/app_db/aws_iam_test.clj | 9 - test/metabase/app_db/cluster_lock_test.clj | 2 - test/metabase/app_db/connection_test.clj | 5 - .../custom_migrations/metrics_v2_test.clj | 2 - .../app_db/custom_migrations_test.clj | 34 --- test/metabase/app_db/env_test.clj | 1 - test/metabase/app_db/force_migration_test.clj | 3 - test/metabase/app_db/internal_user_test.clj | 1 - test/metabase/app_db/liquibase_test.clj | 9 - test/metabase/app_db/query_test.clj | 14 -- .../app_db/schema_migrations_test.clj | 78 ------- .../app_db/schema_migrations_test/impl.clj | 6 - test/metabase/app_db/setup_test.clj | 8 - test/metabase/appearance/settings_test.clj | 31 --- .../audit_app/events/audit_log_test.clj | 14 -- .../audit_app/models/audit_log_test.clj | 2 - test/metabase/audit_app/settings_test.clj | 4 - .../task/truncate_audit_tables_test.clj | 3 - test/metabase/auth_identity/provider_test.clj | 20 -- test/metabase/bookmarks/api_test.clj | 3 - test/metabase/channel/api/channel_test.clj | 15 -- test/metabase/channel/api/email_test.clj | 1 - test/metabase/channel/api/slack_test.clj | 2 - test/metabase/channel/email_test.clj | 8 +- test/metabase/channel/impl/email_test.clj | 5 - test/metabase/channel/impl/http_test.clj | 9 - test/metabase/channel/models/channel_test.clj | 3 - test/metabase/channel/params_test.clj | 2 - test/metabase/channel/render/body_test.clj | 2 - .../metabase/channel/render/js/color_test.clj | 6 - test/metabase/channel/settings_test.clj | 1 - test/metabase/channel/shared_test.clj | 2 - test/metabase/channel/slack_test.clj | 5 - .../channel/template/handlebars_test.clj | 4 - test/metabase/channel/urls_test.clj | 5 - test/metabase/classloader/impl_test.clj | 4 - test/metabase/cloud_migration/api_test.clj | 2 - test/metabase/cmd/copy/h2_test.clj | 2 - test/metabase/cmd/dump_to_h2_test.clj | 4 - test/metabase/cmd/remove_encryption_test.clj | 1 - test/metabase/cmd/reset_password_test.clj | 1 - .../cmd/rotate_encryption_key_test.clj | 7 - .../collections/models/collection_test.clj | 152 ------------ test/metabase/collections/test_utils.clj | 1 - test/metabase/collections_rest/api_test.clj | 50 ---- test/metabase/comments/api_test.clj | 16 -- test/metabase/comments/render_test.clj | 20 -- .../dashboards/models/dashboard_tab_test.clj | 5 - .../dashboards/models/dashboard_test.clj | 3 - test/metabase/dashboards_rest/api_test.clj | 64 ----- test/metabase/data_studio/api/table_test.clj | 24 -- .../documents/api/collection_test.clj | 4 - test/metabase/documents/api/document_test.clj | 219 ------------------ .../documents/models/dashboard_card_test.clj | 3 - .../documents/models/document_test.clj | 29 --- test/metabase/documents/recent_views_test.clj | 6 - .../documents/revisions/impl_test.clj | 2 - .../documents/revisions/integration_test.clj | 22 -- test/metabase/documents/search_test.clj | 9 - test/metabase/documents/view_log_test.clj | 3 - .../driver/common/parameters/parse_test.clj | 2 - .../driver/common/parameters/values_test.clj | 10 - test/metabase/driver/common_test.clj | 4 - .../driver/connection/workspaces_test.clj | 7 - test/metabase/driver/connection_test.clj | 2 - test/metabase/driver/h2_test.clj | 3 - test/metabase/driver/mysql_test.clj | 14 -- test/metabase/driver/postgres_test.clj | 7 - .../driver/sql/parameters/substitute_test.clj | 23 -- test/metabase/driver/sql/transforms_test.clj | 5 - .../metabase/driver/sql_jdbc/actions_test.clj | 4 - .../driver/sql_jdbc/connection_test.clj | 25 -- .../metabase/driver/sql_jdbc/execute_test.clj | 5 - .../sql_jdbc/sync/describe_table_test.clj | 3 - test/metabase/driver/sql_jdbc_test.clj | 14 -- test/metabase/driver/util_test.clj | 29 --- test/metabase/driver_test.clj | 3 - test/metabase/eid_translation/util_test.clj | 5 - test/metabase/embedding/settings_test.clj | 7 - .../embedding_rest/api/embed_test.clj | 32 --- .../embedding_rest/api/preview_embed_test.clj | 21 -- test/metabase/events/recent_views_test.clj | 3 - test/metabase/formatter/datetime_test.clj | 2 - test/metabase/frontend_errors/api_test.clj | 6 - test/metabase/glossary/api_test.clj | 9 - .../glossary/models/glossary_test.clj | 2 - test/metabase/graph/core_test.clj | 7 - test/metabase/indexed_entities/api_test.clj | 2 - .../interestingness/chart/stats_test.clj | 1 - test/metabase/interestingness/impl_test.clj | 24 -- test/metabase/legacy_mbql/normalize_test.cljc | 7 - test/metabase/legacy_mbql/util_test.cljc | 8 - test/metabase/lib/aggregation_test.cljc | 1 - test/metabase/lib/card_test.cljc | 1 - test/metabase/lib/convert_test.cljc | 4 - test/metabase/lib/display_name_test.cljc | 13 -- .../lib/drill_thru/fk_details_test.cljc | 2 - .../lib/drill_thru/fk_filter_test.cljc | 1 - test/metabase/lib/drill_thru/pk_test.cljc | 1 - .../lib/drill_thru/test_util/canned.cljc | 26 --- .../lib/drill_thru/zoom_in_bins_test.cljc | 4 - .../drill_thru/zoom_in_geographic_test.cljc | 5 - test/metabase/lib/equality_test.cljc | 1 - test/metabase/lib/expression_test.cljc | 1 - test/metabase/lib/fe_util_test.cljc | 7 - test/metabase/lib/field_test.cljc | 12 - test/metabase/lib/filter_test.cljc | 13 -- test/metabase/lib/join_test.cljc | 2 - test/metabase/lib/js_test.cljs | 11 - .../lib/metadata/calculation_test.cljc | 4 - test/metabase/lib/native_test.cljc | 4 - test/metabase/lib/order_by_test.cljc | 1 - test/metabase/lib/parameters/parse_test.cljc | 2 - test/metabase/lib/query/test_spec_test.cljc | 52 ----- test/metabase/lib/query_test.cljc | 1 - test/metabase/lib/remove_replace_test.cljc | 2 - .../schema/expression/arithmetic_test.cljc | 1 - test/metabase/lib/schema/literal_test.cljc | 1 - test/metabase/lib/schema/metadata_test.cljc | 2 - test/metabase/lib/schema/order_by_test.cljc | 1 - test/metabase/lib/schema/ref_test.cljc | 2 - test/metabase/lib/test_util/generators.cljc | 2 - test/metabase/lib/underlying_test.cljc | 3 - test/metabase/lib_metric/ast/build_test.cljc | 36 --- .../metabase/lib_metric/ast/compile_test.cljc | 23 -- test/metabase/lib_metric/ast/walk_test.cljc | 8 - .../lib_metric/dimension/jvm_test.clj | 1 - test/metabase/lib_metric/dimension_test.cljc | 2 - test/metabase/llm/anthropic_test.clj | 8 - test/metabase/llm/api_test.clj | 10 - test/metabase/llm/context_test.clj | 39 ---- test/metabase/llm/settings_test.clj | 12 - test/metabase/logger/api_test.clj | 8 - test/metabase/logger/core_test.clj | 3 - .../models/login_history_test.clj | 5 - test/metabase/mcp/api_test.clj | 9 - test/metabase/measures/api_test.clj | 8 - .../metabase/measures/models/measure_test.clj | 4 - test/metabase/metabot/agent/core_test.clj | 14 -- .../agent/markdown_link_buffer_test.clj | 12 - test/metabase/metabot/agent/memory_test.clj | 4 - test/metabase/metabot/agent/messages_test.clj | 17 -- test/metabase/metabot/agent/profiles_test.clj | 9 - test/metabase/metabot/agent/prompts_test.clj | 21 -- .../metabot/agent/scope_enforcement_test.clj | 10 - .../metabase/metabot/agent/streaming_test.clj | 21 -- .../metabot/agent/tool_output_test.clj | 5 - .../metabot/agent/user_context_test.clj | 33 --- test/metabase/metabot/api/metabot_test.clj | 24 -- test/metabase/metabot/api_test.clj | 8 - test/metabase/metabot/config_test.clj | 7 - test/metabase/metabot/context_test.clj | 5 - .../metabase/metabot/middleware/auth_test.clj | 3 - test/metabase/metabot/models/metabot_test.clj | 2 - .../native_generation_integration_test.clj | 4 - test/metabase/metabot/persistence_test.clj | 12 - test/metabase/metabot/scope_test.clj | 6 - test/metabase/metabot/self/claude_test.clj | 12 - test/metabase/metabot/self/openai_test.clj | 3 - .../metabase/metabot/self/openrouter_test.clj | 3 - test/metabase/metabot/self_test.clj | 29 --- test/metabase/metabot/table_utils_test.clj | 36 --- .../task/suggested_prompts_generator_test.clj | 6 - .../metabot/tools/autogen_dashboard_test.clj | 11 - .../metabot/tools/charts/create_test.clj | 3 - .../metabot/tools/charts/edit_test.clj | 3 - .../metabot/tools/clarification_test.clj | 4 - test/metabase/metabot/tools/deftool_test.clj | 3 - test/metabase/metabot/tools/document_test.clj | 4 - .../metabot/tools/entity_details_test.clj | 5 - .../metabot/tools/navigation_test.clj | 15 -- .../metabase/metabot/tools/resources_test.clj | 29 --- test/metabase/metabot/tools/search_test.clj | 21 -- .../tools/shared/llm_representations_test.clj | 25 -- .../metabase/metabot/tools/sql/tools_test.clj | 5 - .../metabot/tools/subscriptions_test.clj | 1 - test/metabase/metabot/tools/todo_test.clj | 9 - .../metabot/tools/transforms/write_test.clj | 7 - test/metabase/metabot/tools/util_test.clj | 13 -- test/metabase/metabot/tools_test.clj | 9 - test/metabase/metrics/api_test.clj | 1 - test/metabase/model_persistence/api_test.clj | 2 - test/metabase/models/interface_test.clj | 1 - test/metabase/models/on_demand_test.clj | 1 - test/metabase/models/resolution_test.clj | 1 - .../metabase/models/util/spec_update_test.clj | 3 - .../native_query_snippets/api_test.clj | 13 -- .../models/native_query_snippet_test.clj | 6 - .../notification/api/notification_test.clj | 71 ------ .../notification/api/unsubscribe_test.clj | 5 - test/metabase/notification/models_test.clj | 17 -- .../notification/payload/impl/card_test.clj | 10 - .../payload/impl/system_event_test.clj | 10 - .../payload/temp_storage_test.clj | 5 - test/metabase/notification/send_test.clj | 38 --- test/metabase/notification/task/send_test.clj | 8 - test/metabase/notification/test_util.clj | 2 - .../metabase/parameters/chain_filter_test.clj | 11 - test/metabase/parameters/dashboard_test.clj | 14 -- test/metabase/parameters/field_test.clj | 1 - test/metabase/parameters/params_test.clj | 5 - test/metabase/parameters/shared_test.cljc | 1 - .../models/collection/graph_test.clj | 14 -- .../models/data_permissions_test.clj | 54 ----- .../permissions_group_membership_test.clj | 3 - .../models/permissions_group_test.clj | 3 - .../permissions/models/permissions_test.clj | 4 - test/metabase/permissions/user_test.clj | 1 - test/metabase/permissions/util_test.clj | 3 - test/metabase/permissions_rest/api_test.clj | 30 --- .../graph/bulk_update_test.clj | 1 - test/metabase/pivot/core_test.cljc | 28 --- test/metabase/premium_features/api_test.clj | 5 - .../premium_features/defenterprise_test.clj | 11 - .../premium_features/token_check_test.clj | 10 - .../task/creator_sentiment_emails_test.clj | 1 - .../api_documents_test.clj | 5 - .../metabase/public_sharing_rest/api_test.clj | 37 --- test/metabase/pulse/api/alert_test.clj | 10 - test/metabase/pulse/api/pulse_test.clj | 34 --- test/metabase/pulse/api/unsubscribe_test.clj | 5 - .../pulse/dashboard_subscription_test.clj | 11 - .../pulse/models/pulse_channel_test.clj | 14 -- test/metabase/pulse/models/pulse_test.clj | 13 -- test/metabase/pulse/send_test.clj | 7 - test/metabase/pulse/task/send_pulses_test.clj | 12 - test/metabase/queries/models/card_test.clj | 18 -- test/metabase/queries_rest/api/card_test.clj | 43 ---- test/metabase/queries_rest/api/cards_test.clj | 1 - test/metabase/query_permissions/impl_test.clj | 1 - test/metabase/query_processor/api_test.clj | 5 - test/metabase/query_processor/card_test.clj | 1 - test/metabase/query_processor/cast_test.clj | 2 - .../query_processor/coercion_test.clj | 2 - .../query_processor/dashboard_test.clj | 5 - .../date_time_zone_functions_test.clj | 8 - .../query_processor/distinct_where_test.clj | 1 - .../expression_aggregations_test.clj | 1 - .../query_processor/field_visibility_test.clj | 1 - .../middleware/add_implicit_clauses_test.clj | 1 - .../middleware/add_remaps_test.clj | 1 - .../middleware/binning_test.clj | 1 - .../query_processor/middleware/cache_test.clj | 1 - .../middleware/catch_exceptions_test.clj | 1 - .../middleware/fetch_source_query_test.clj | 1 - .../middleware/format_rows_test.clj | 19 -- .../middleware/metrics_test.clj | 1 - .../middleware/parameters_test.clj | 1 - .../middleware/permissions_test.clj | 82 ------- .../process_userland_query_test.clj | 2 - ...e_breakout_and_order_by_bucketing_test.clj | 1 - .../middleware/resolve_referenced_test.clj | 5 - .../middleware/resolve_source_table_test.clj | 1 - .../middleware/results_metadata_test.clj | 1 - .../middleware/update_used_cards_test.clj | 1 - .../query_processor/nested_field_test.clj | 1 - .../query_processor/nested_queries_test.clj | 8 - .../pivot/postprocess_test.clj | 9 - .../query_processor/split_part_test.clj | 6 - .../query_processor/streaming/common_test.clj | 2 - .../query_processor/streaming/csv_test.clj | 1 - .../query_processor/streaming/xlsx_test.clj | 5 - .../query_processor/streaming_test.clj | 3 - .../query_processor/sum_where_test.clj | 1 - test/metabase/query_processor/test_util.clj | 1 - .../query_processor/timeseries_test.clj | 49 ---- test/metabase/remote_sync/init_test.clj | 5 - test/metabase/request/util_test.clj | 2 - test/metabase/revisions/api_test.clj | 29 --- test/metabase/revisions/events_test.clj | 1 - test/metabase/revisions/impl/card_test.clj | 1 - .../revisions/impl/dashboard_test.clj | 38 --- .../revisions/models/revision_test.clj | 11 - test/metabase/sample_data/impl_test.clj | 3 - test/metabase/search/api_test.clj | 60 ----- test/metabase/search/appdb/index_test.clj | 13 -- test/metabase/search/appdb/scoring_test.clj | 2 - test/metabase/search/filter_test.clj | 6 - test/metabase/search/impl_test.clj | 2 - test/metabase/search/in_place/filter_test.clj | 15 -- .../metabase/search/in_place/scoring_test.clj | 4 - test/metabase/search/ingestion_test.clj | 8 - test/metabase/search/spec_test.clj | 1 - test/metabase/search/util_test.clj | 14 -- test/metabase/secrets/models/secret_test.clj | 6 - test/metabase/segments/api_test.clj | 9 - .../metabase/segments/models/segment_test.clj | 2 - test/metabase/server/lib/etag_cache_test.clj | 3 - test/metabase/server/middleware/auth_test.clj | 4 - .../middleware/embedding_sdk_bundle_test.clj | 3 - .../server/middleware/exceptions_test.clj | 1 - test/metabase/server/middleware/log_test.clj | 1 - .../server/middleware/security_mcp_test.clj | 7 - .../server/middleware/security_test.clj | 11 - .../server/middleware/session_test.clj | 8 - .../server/middleware/settings_cache_test.clj | 1 - test/metabase/server/middleware/ssl_test.clj | 2 - test/metabase/server/routes/index_test.clj | 1 - test/metabase/server/settings_test.clj | 1 - .../server/streaming_response_test.clj | 2 - test/metabase/session/api_test.clj | 14 -- test/metabase/session/models/session_test.clj | 5 - .../settings/models/setting/cache_test.clj | 4 - .../metabase/settings/models/setting_test.clj | 57 ----- test/metabase/settings_rest/api_test.clj | 29 --- test/metabase/setup/core_test.clj | 1 - test/metabase/slackbot/api_test.clj | 30 --- test/metabase/slackbot/config_test.clj | 6 - test/metabase/slackbot/persistence_test.clj | 6 - test/metabase/slackbot/query_test.clj | 21 -- test/metabase/slackbot/streaming_test.clj | 4 - test/metabase/source_swap/native_test.clj | 13 -- test/metabase/sql_parsing/core_test.clj | 23 -- test/metabase/sql_parsing/pool_test.clj | 10 - test/metabase/sso/api/ldap_test.clj | 9 - test/metabase/sso/common_test.clj | 2 - test/metabase/sso/google_test.clj | 4 - .../sso/integrations/slack_connect_test.clj | 1 - test/metabase/sso/ldap_test.clj | 16 -- test/metabase/sso/oidc/common_test.clj | 20 -- test/metabase/sso/oidc/discovery_test.clj | 20 -- test/metabase/sso/oidc/http_test.clj | 5 - test/metabase/sso/oidc/schema_test.clj | 4 - test/metabase/sso/oidc/state_test.clj | 24 -- test/metabase/sso/oidc/tokens_test.clj | 8 - test/metabase/sso/providers/oidc_test.clj | 10 - .../sso/providers/slack_connect_test.clj | 9 - test/metabase/sso/settings_test.clj | 1 - test/metabase/sync/analyze/classify_test.clj | 18 -- test/metabase/sync/analyze_test.clj | 2 - test/metabase/sync/field_values_test.clj | 10 - .../fields/our_metadata_test.clj | 1 - .../fields/sync_instances_test.clj | 1 - .../fields/sync_metadata_test.clj | 3 - .../sync/sync_metadata/fields_test.clj | 15 -- .../sync/sync_metadata/tables_test.clj | 15 -- test/metabase/sync/sync_test.clj | 5 - .../sync/task/sync_databases_test.clj | 5 - test/metabase/sync/util_test.clj | 6 - test/metabase/task/job_factory_test.clj | 2 - test/metabase/task_history/api_test.clj | 4 - .../task_history/models/task_history_test.clj | 5 - .../task_history/models/task_run_test.clj | 2 - .../task/task_run_heartbeat_test.clj | 2 - test/metabase/task_test.clj | 1 - test/metabase/test.clj | 33 --- test/metabase/test/data/sql.clj | 1 - test/metabase/test/util.clj | 12 - test/metabase/test/util_test.clj | 7 - .../assert_exprs/malli_equals.cljc | 1 - test/metabase/tracing/core_test.clj | 3 - test/metabase/transforms/execute_test.clj | 1 - test/metabase/transforms/jobs_test.clj | 5 - .../transforms/models/transform_job_test.clj | 1 - .../transforms/models/transform_test.clj | 1 - test/metabase/transforms/models_test.clj | 22 -- test/metabase/transforms/search_test.clj | 3 - test/metabase/transforms/test_dataset.clj | 2 - test/metabase/transforms/util_test.clj | 25 -- .../transforms_base/ordering_unit_test.clj | 8 - .../api/transform_job_test.clj | 6 - .../api/transform_tag_test.clj | 8 - .../transforms_rest/api/transform_test.clj | 10 - test/metabase/types/core_test.cljc | 2 - test/metabase/upload/impl_test.clj | 27 --- test/metabase/upload/types_test.clj | 2 - .../models/user_parameter_value_test.clj | 4 - test/metabase/users_rest/api_test.clj | 41 ---- test/metabase/util/compress_test.clj | 3 - test/metabase/util/date_2_test.clj | 19 -- test/metabase/util/formatting/date_test.cljc | 2 - .../internal/date_builder_test.cljc | 2 - test/metabase/util/honey_sql_2_test.clj | 20 -- test/metabase/util/http_test.clj | 3 - test/metabase/util/i18n/impl_test.clj | 7 - test/metabase/util/i18n/plural_test.clj | 8 - test/metabase/util/i18n_test.clj | 21 -- test/metabase/util/log_test.clj | 5 - test/metabase/util/malli/defn_test.clj | 5 - test/metabase/util/malli/fn_test.clj | 4 - test/metabase/util/malli/schema_test.clj | 1 - test/metabase/util/malli_test.cljc | 8 - test/metabase/util/match_test.cljc | 10 - test/metabase/util/ordered_hierarchy_test.clj | 1 - test/metabase/util/performance_test.cljc | 79 +++---- test/metabase/util/queue_test.clj | 26 --- test/metabase/util/retry_test.clj | 1 - test/metabase/util/string_test.clj | 4 - test/metabase/util/time_test.cljc | 12 - test/metabase/util_test.cljc | 5 - test/metabase/version/settings_test.clj | 1 - .../version/task/upgrade_checks_test.clj | 2 - .../view_log/events/view_log_test.clj | 7 - .../warehouse_schema/models/field_test.clj | 1 - .../models/field_values_test.clj | 11 - .../warehouse_schema/models/table_test.clj | 5 - .../warehouse_schema_rest/api/field_test.clj | 8 - .../warehouse_schema_rest/api/table_test.clj | 24 -- .../warehouses/models/database_test.clj | 19 -- .../warehouses/provider_detection_test.clj | 1 - test/metabase/warehouses/settings_test.clj | 1 - test/metabase/warehouses_rest/api_test.clj | 33 --- .../xrays/api/automagic_dashboards_test.clj | 7 - .../dashboard_templates_test.clj | 1 - .../automagic_dashboards/populate_test.clj | 2 - .../domain_entities/converters_test.cljs | 8 - test/metabase/xrays/transforms/core_test.clj | 3 - 787 files changed, 101 insertions(+), 6826 deletions(-) diff --git a/.cljfmt.edn b/.cljfmt.edn index 13c573f60d9e..75204d52f1b3 100644 --- a/.cljfmt.edn +++ b/.cljfmt.edn @@ -3,6 +3,7 @@ :indent-line-comments? true :normalize-newlines-at-file-end? true :parallel? true + :remove-blank-lines-in-forms? true :sort-ns-references? true :paths @@ -12,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 ;; @@ -34,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/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/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/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/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 ed62f4993f90..d1bebdb42762 100644 --- a/bin/release-list/src/release_list/main.clj +++ b/bin/release-list/src/release_list/main.clj @@ -98,7 +98,6 @@ ;; Clear existing list of releases (let [target "../../docs/releases.md"] (shell (str "rm -rf " target)) - ;; Publish releases (spit target (-> list-of-releases 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/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/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 ba4148349fb3..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] 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/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/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/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 4462e2206301..1401cc9347b2 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/index.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/index.clj @@ -760,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 @@ -853,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) @@ -863,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) @@ -875,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/sso/integrations/saml.clj b/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj index 431ed1f294f8..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)) diff --git a/enterprise/backend/src/metabase_enterprise/sso/providers/oidc.clj b/enterprise/backend/src/metabase_enterprise/sso/providers/oidc.clj index 7eb5501bcf23..bc87c0944cee 100644 --- a/enterprise/backend/src/metabase_enterprise/sso/providers/oidc.clj +++ b/enterprise/backend/src/metabase_enterprise/sso/providers/oidc.clj @@ -74,7 +74,6 @@ {:success? false :error :configuration-error :message (tru "Failed to build OIDC configuration for provider ''{0}''" provider-key)} - (let [auth-result (next-method _provider (assoc request :oidc-config oidc-config))] (if (and (:success? auth-result) (:user-data auth-result)) diff --git a/enterprise/backend/src/metabase_enterprise/stale/api.clj b/enterprise/backend/src/metabase_enterprise/stale/api.clj index 84ffb1317c72..dc762c24169c 100644 --- a/enterprise/backend/src/metabase_enterprise/stale/api.clj +++ b/enterprise/backend/src/metabase_enterprise/stale/api.clj @@ -114,7 +114,6 @@ [nil :dashboard_id] [nil :location] [nil :database_id]] - :id [:in (set (map :id dashboards))]) :can_write :can_delete :can_restore [:collection :effective_location]) annotate-dashboard-with-collection-info diff --git a/enterprise/backend/src/metabase_enterprise/support_access_grants/core.clj b/enterprise/backend/src/metabase_enterprise/support_access_grants/core.clj index e4b8d0784417..0abf51f86fc5 100644 --- a/enterprise/backend/src/metabase_enterprise/support_access_grants/core.clj +++ b/enterprise/backend/src/metabase_enterprise/support_access_grants/core.clj @@ -49,7 +49,6 @@ token (sag.provider/create-support-access-reset! (:id support-user) grant) password-reset-url (when token (str (system/site-url) "/auth/reset_password/" token))] - ;; Publish event - the notification system handles email sending automatically (when (and token password-reset-url) (events/publish-event! :event/support-access-grant-created @@ -59,7 +58,6 @@ :grant_end_time grant-end :password_reset_url password-reset-url :notes notes})) - ;; Return grant with token (cond-> grant token (assoc :token token))))) diff --git a/enterprise/backend/src/metabase_enterprise/transforms_python/base.clj b/enterprise/backend/src/metabase_enterprise/transforms_python/base.clj index 1620e9be98cf..02954cf46fdb 100644 --- a/enterprise/backend/src/metabase_enterprise/transforms_python/base.clj +++ b/enterprise/backend/src/metabase_enterprise/transforms_python/base.clj @@ -318,7 +318,6 @@ ;; Check cancellation before starting (when (and cancelled? (cancelled?)) (throw (ex-info "Transform cancelled before start" {:status :cancelled}))) - (let [{:keys [target] transform-id :id} transform db (t2/select-one :model/Database (:database target)) ;; Use run-id if provided, otherwise generate a temp one for python runner @@ -337,25 +336,20 @@ (recur))))))) ch)) start-ms (u/start-timer)] - (log! message-log (i18n/tru "Executing Python transform")) (log/info "Executing Python transform" transform-id "with target" (pr-str target)) - (let [result (run-python-transform-impl! transform db effective-run-id cancel-chan message-log {:with-stage-timing-fn with-stage-timing-fn :source-range-params source-range-params})] (log! message-log (i18n/tru "Python execution finished successfully in {0}" (u.format/format-milliseconds (u/since-ms start-ms)))) - ;; Check cancellation after python (when (and cancelled? (cancelled?)) (throw (ex-info "Transform cancelled after python execution" {:status :cancelled}))) - {:status :succeeded :result result :logs (message-log->string message-log) :source-range-params source-range-params})) - (catch Exception e (let [data (ex-data e) logs (message-log->string message-log) diff --git a/enterprise/backend/test/metabase_enterprise/action_v2/api_test.clj b/enterprise/backend/test/metabase_enterprise/action_v2/api_test.clj index bc9bff92eb62..767d0f008089 100644 --- a/enterprise/backend/test/metabase_enterprise/action_v2/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/action_v2/api_test.clj @@ -51,7 +51,6 @@ (action-v2.tu/with-test-tables! [table-id action-v2.tu/default-test-table] (testing "Initially the table is empty" (is (= [] (table-rows table-id)))) - (testing "POST should insert new rows" (is (= #{{:op "created", :table-id table-id, :row {:id 1, :name "Pidgey", :song "Car alarms"}} {:op "created", :table-id table-id, :row {:id 2, :name "Spearow", :song "Hold music"}} @@ -61,12 +60,10 @@ (action-v2.tu/create-rows! table-id [{:name "Pidgey" :song "Car alarms"} {:name "Spearow" :song "Hold music"} {:name "Farfetch'd" :song "The land of lisp"}]))))) - (is (= [[1 "Pidgey" "Car alarms"] [2 "Spearow" "Hold music"] [3 "Farfetch'd" "The land of lisp"]] (table-rows table-id)))) - (testing "PUT should update the relevant rows and columns" (is (= #{{:op "updated", :table-id table-id :row {:id 1, :name "Pidgey", :song "Join us now and share the software"}} {:op "updated", :table-id table-id :row {:id 2, :name "Speacolumn", :song "Hold music"}}} @@ -74,24 +71,20 @@ (:outputs (action-v2.tu/update-rows! table-id [{:id 1 :song "Join us now and share the software"} {:id 2 :name "Speacolumn"}]))))) - (is (= #{[1 "Pidgey" "Join us now and share the software"] [2 "Speacolumn" "Hold music"] [3 "Farfetch'd" "The land of lisp"]} (set (table-rows table-id))))) - (testing "PUT can also do bulk updates" (is (= #{{:op "updated", :table-id table-id, :row {:id 1, :name "Pidgey", :song "The Star-Spangled Banner"}} {:op "updated", :table-id table-id, :row {:id 2, :name "Speacolumn", :song "The Star-Spangled Banner"}}} (set (:outputs (action-v2.tu/update-rows! table-id [{:id 1} {:id 2}] {:song "The Star-Spangled Banner"}))))) - (is (= #{[1 "Pidgey" "The Star-Spangled Banner"] [2 "Speacolumn" "The Star-Spangled Banner"] [3 "Farfetch'd" "The land of lisp"]} (set (table-rows table-id))))) - (testing "DELETE should remove the corresponding rows" (is (= #{{:op "deleted", :table-id table-id, :row {:id 1}} {:op "deleted", :table-id table-id, :row {:id 2}}} @@ -114,7 +107,6 @@ id-3 (random-uuid)] (testing "Initially the table is empty" (is (= [] (table-rows table-id)))) - (testing "POST should insert new rows" (is (= #{{:op "created", :table-id table-id, :row {:id (str id-1), :name "Pidgey", :song "Car alarms"}} {:op "created", :table-id table-id, :row {:id (str id-2), :name "Spearow", :song "Hold music"}} @@ -124,12 +116,10 @@ (action-v2.tu/create-rows! table-id [{:id id-1, :name "Pidgey" :song "Car alarms"} {:id id-2, :name "Spearow" :song "Hold music"} {:id id-3, :name "Farfetch'd" :song "The land of lisp"}]))))) - (is (= [[id-1 "Pidgey" "Car alarms"] [id-2 "Spearow" "Hold music"] [id-3 "Farfetch'd" "The land of lisp"]] (table-rows table-id)))) - (testing "PUT should update the relevant rows and columns" (is (= #{{:op "updated", :table-id table-id :row {:id (str id-1), :name "Pidgey", :song "Join us now and share the software"}} {:op "updated", :table-id table-id :row {:id (str id-2), :name "Speacolumn", :song "Hold music"}}} @@ -137,12 +127,10 @@ (:outputs (action-v2.tu/update-rows! table-id [{:id id-1, :song "Join us now and share the software"} {:id id-2, :name "Speacolumn"}]))))) - (is (= #{[id-1 "Pidgey" "Join us now and share the software"] [id-2 "Speacolumn" "Hold music"] [id-3 "Farfetch'd" "The land of lisp"]} (set (table-rows table-id))))) - (testing "PUT can also do bulk updates" (is (= #{{:op "updated", :table-id table-id, :row {:id (str id-1), :name "Pidgey", :song "The Star-Spangled Banner"}} {:op "updated", :table-id table-id, :row {:id (str id-2), :name "Speacolumn", :song "The Star-Spangled Banner"}}} @@ -152,12 +140,10 @@ [{:id id-1} {:id id-2}] {:song "The Star-Spangled Banner"}))))) - (is (= #{[id-1 "Pidgey" "The Star-Spangled Banner"] [id-2 "Speacolumn" "The Star-Spangled Banner"] [id-3 "Farfetch'd" "The land of lisp"]} (set (table-rows table-id))))) - (testing "DELETE should remove the corresponding rows" (is (= #{{:op "deleted", :table-id table-id, :row {:id (str id-1)}} {:op "deleted", :table-id table-id, :row {:id (str id-2)}}} @@ -179,7 +165,6 @@ {:primary-key [:id_1 :id_2]}]] (testing "Initially the table is empty" (is (= [] (table-rows table-id)))) - (testing "POST should insert new rows" (is (= #{{:op "created", :table-id table-id, :row {:id_1 1, :id_2 0, :name "Pidgey", :song "Car alarms"}} {:op "created", :table-id table-id, :row {:id_1 2, :id_2 0, :name "Spearow", :song "Hold music"}} @@ -189,12 +174,10 @@ (action-v2.tu/create-rows! table-id [{:id_2 0 :name "Pidgey" :song "Car alarms"} {:id_2 0 :name "Spearow" :song "Hold music"} {:id_2 0 :name "Farfetch'd" :song "The land of lisp"}]))))) - (is (= [[1 0 "Pidgey" "Car alarms"] [2 0 "Spearow" "Hold music"] [3 0 "Farfetch'd" "The land of lisp"]] (table-rows table-id)))) - (testing "PUT should update the relevant rows and columns" (is (= #{{:op "updated", :table-id table-id :row {:id_1 1, :id_2 0, :name "Pidgey", :song "Join us now and share the software"}} {:op "updated", :table-id table-id :row {:id_1 2, :id_2 0, :name "Speacolumn", :song "Hold music"}}} @@ -202,12 +185,10 @@ (:outputs (action-v2.tu/update-rows! table-id [{:id_1 1, :id_2 0, :song "Join us now and share the software"} {:id_1 2, :id_2 0, :name "Speacolumn"}]))))) - (is (= #{[1 0 "Pidgey" "Join us now and share the software"] [2 0 "Speacolumn" "Hold music"] [3 0 "Farfetch'd" "The land of lisp"]} (set (table-rows table-id))))) - (testing "PUT can also do bulk updates" (is (= #{{:op "updated", :table-id table-id, :row {:id_1 1, :id_2 0, :name "Pidgey", :song "The Star-Spangled Banner"}} {:op "updated", :table-id table-id, :row {:id_1 2, :id_2 0, :name "Speacolumn", :song "The Star-Spangled Banner"}}} @@ -217,12 +198,10 @@ [{:id_1 1, :id_2 0} {:id_1 2, :id_2 0}] {:song "The Star-Spangled Banner"}))))) - (is (= #{[1 0 "Pidgey" "The Star-Spangled Banner"] [2 0 "Speacolumn" "The Star-Spangled Banner"] [3 0 "Farfetch'd" "The land of lisp"]} (set (table-rows table-id))))) - (testing "DELETE should remove the corresponding rows" (is (= #{{:op "deleted", :table-id table-id, :row {:id_1 1, :id_2 0}} {:op "deleted", :table-id table-id, :row {:id_1 2, :id_2 0}}} @@ -249,10 +228,8 @@ :breakout [(mt/$ids $orders.product_id)] :filter [:in (mt/$ids $orders.product_id) 1 2]}}))] (zipmap (map first result) (map second result))))] - ;; TODO waiting on https://github.com/metabase/metabase/pull/62485 (t2/update! :model/Table {:db_id (mt/id)} {:is_writable true}) - (testing "sanity check that we have children rows" (is (= {1 93 2 98} @@ -270,7 +247,6 @@ :status-code 400}]} (mt/user-http-request :crowberto :post 400 execute-bulk-url body)))) - ;; TODO: an edge case we could handle in the future #_(testing "success with delete-children options" (is (=? {:outputs [{:table-id (mt/id :products) :op "deleted" :row {(keyword (mt/format-name :id)) 1}} @@ -308,15 +284,12 @@ :aggregation [[:count]] :filter [:= (mt/$ids $category.parent_id) parent-id]}}))] (-> result first first)))] - (testing "sanity check that we have self-referential children" (is (= 2 (children-count 1))) (is (= 1 (children-count 2))) (is (= 1 (children-count 3)))) - ;; TODO waiting on https://github.com/metabase/metabase/pull/62485 (t2/update! :model/Table {:db_id (mt/id)} {:is_writable true}) - (testing "delete parent with self-referential children should return error without delete-children param" (is (=? {:errors [{:index 0 :type "metabase.actions.error/violate-foreign-key-constraint", @@ -324,7 +297,6 @@ :errors {} :status-code 400}]} (mt/user-http-request :crowberto :post 400 execute-bulk-url body)))) - ;; TODO: same with the test above, this is one of the case where we want to handle in the future #_(testing "success with delete-children option should cascade delete all descendants" (is (=? {:outputs [{:table-id (mt/id :category) @@ -333,7 +305,6 @@ (mt/user-http-request :crowberto :post 200 execute-bulk-url (assoc body :params {:delete-children true})))) (is (= 0 (count (table-rows (mt/id :category))))) - (testing "the change is not undoable for self-referential cascades" (is (= "Your previous change cannot be undone" (mt/user-http-request :crowberto :post 405 execute-bulk-url @@ -366,16 +337,13 @@ {:table-name "user"} {:fk :team :field-name "team_id"}) {:transaction? false}) - ;; TODO waiting on https://github.com/metabase/metabase/pull/62485 (t2/update! :model/Table {:db_id (mt/id)} {:is_writable true}) - (let [users-table-id (mt/id :user) #_teams-table-id #_(mt/id :team) delete-user-body {:action "data-grid.row/delete" :scope {:table-id users-table-id} :inputs [{(mt/format-name :id) 1}]}] - (testing "delete user involved in mutual recursion should return error without delete-children param" (is (=? {:errors [{:index 0 :type "metabase.actions.error/violate-foreign-key-constraint", @@ -394,7 +362,6 @@ :row {(keyword (mt/format-name :id)) 1}}]} (mt/user-http-request :crowberto :post 200 execute-bulk-url (assoc delete-user-body :params {:delete-children true})))) - (let [remaining-users (table-rows users-table-id) remaining-teams (table-rows teams-table-id)] (testing "mutual recursion cascade should delete interconnected records" @@ -499,7 +466,6 @@ (is (= [qp-row] (map :row outputs))) (is (= input (:o qp-row)))) (is (= expected (:o (first (get-db-state)))))))))))] - ;; type coercion input database (->> (concat [:text nil "a" "a" @@ -510,18 +476,14 @@ [:text :Coercion/ISO8601->DateTime "2025-03-25T14:34:42Z" "2025-03-25T14:34:42Z"]) [:text :Coercion/ISO8601->Date "2025-03-25T00:00:00Z" "2025-03-25" :text :Coercion/ISO8601->Time "1999-04-05T14:34:42Z" "14:34:42" - ;; note fractional seconds in input, remains undefined for Seconds :int :Coercion/UNIXSeconds->DateTime "2025-03-25T14:34:42Z" (quot (inst-ms #inst "2025-03-25T14:34:42Z") 1000) :bigint :Coercion/UNIXMilliSeconds->DateTime "2025-03-25T14:34:42.314Z" (inst-ms #inst "2025-03-25T14:34:42.314Z") - ;; note fractional secs beyond millis are discarded (lossy) :bigint :Coercion/UNIXMicroSeconds->DateTime "2025-03-25T14:34:42.314121Z" (* (inst-ms #inst "2025-03-25T14:34:42.314Z") 1000) :bigint :Coercion/UNIXNanoSeconds->DateTime "2025-03-25T14:34:42.3141212Z" (* (inst-ms #inst "2025-03-25T14:34:42.314Z") 1000000) - ;; nil safe :text :Coercion/YYYYMMDDHHMMSSString->Temporal nil nil - ;; seconds component does not work properly here, lost by qp output, bug in existing code? #_#_#_#_:text :Coercion/YYYYMMDDHHMMSSString->Temporal "2025-03-25T14:34:42Z" "20250325143442"]) (partition 4) @@ -544,22 +506,18 @@ (recur)))] (binding [data-editing/*field-value-invalidate-queue* test-queue] (is (= [] (field-values))) - (create! [{:n "a"}]) (is (pos? (.size test-queue))) (process-queue!) (is (= ["a"] (field-values))) - (create! [{:n "b"} {:n "c"}]) (is (pos? (.size test-queue))) (process-queue!) (is (= ["a" "b" "c"] (field-values))) - (update! [{:id 2, :n "d"}]) (is (pos? (.size test-queue))) (process-queue!) (is (= ["a" "c" "d"] (field-values))) - (create! [{:n "a"}]) (is (zero? (.size test-queue))) (process-queue!) @@ -583,7 +541,6 @@ {:scope {:table-id table-id} :action "data-grid.row/create"}) [:message]))))))) - (testing "Non auto-incrementing pk" (action-v2.tu/with-test-tables! [table-id [(ordered-map :id [:int] @@ -603,7 +560,6 @@ update-id "data-grid.row/update" delete-id "data-grid.row/delete" scope {:table-id table-id}] - (testing "create" (is (=? {:parameters [{:id "id" :display_name "ID" :input_type "integer" :optional false :readonly false} {:id "text" :display_name "Text" :input_type "text" :optional true :readonly false} @@ -616,7 +572,6 @@ (mt/user-http-request :crowberto :post 200 execute-form-url {:scope scope :action create-id})))) - (testing "update" (is (=? {:parameters [{:id "id" :display_name "ID" :input_type "dropdown" :optional false :readonly true} {:id "text" :display_name "Text" :input_type "text" :optional true :readonly false} @@ -629,13 +584,11 @@ (mt/user-http-request :crowberto :post 200 execute-form-url {:scope scope :action update-id})))) - (testing "delete" (is (=? {:parameters [{:id "id" :display_name "ID" :input_type "dropdown" :optional false :readonly true}]} (mt/user-http-request :crowberto :post 200 execute-form-url {:scope scope :action delete-id})))))))) - (testing "Auto incrementing pk" (action-v2.tu/with-test-tables! [table-id [(ordered-map :id 'auto-inc-type @@ -655,7 +608,6 @@ update-id "data-grid.row/update" delete-id "data-grid.row/delete" scope {:table-id table-id}] - (testing "create" (is (=? {:parameters [{:id "text" :display_name "Text" :input_type "text", :optional true, :readonly false} {:id "int" :display_name "Int" :input_type "integer", :optional true, :readonly false} @@ -667,7 +619,6 @@ (mt/user-http-request :crowberto :post 200 execute-form-url {:scope scope :action create-id})))) - (testing "update" (is (=? {:parameters [{:id "id" :display_name "ID" :input_type "dropdown", :optional false, :readonly true} {:id "text" :display_name "Text" :input_type "text", :optional true, :readonly false} @@ -680,7 +631,6 @@ (mt/user-http-request :crowberto :post 200 execute-form-url {:scope scope :action update-id})))) - (testing "delete" (is (=? {:parameters [{:id "id" :display_name "ID" :input_type "dropdown", :optional false, :readonly true}]} (mt/user-http-request :crowberto :post 200 execute-form-url @@ -697,7 +647,6 @@ :active [:boolean] :created_at [:timestamp]} {:primary-key [:id]}]] - (testing "Valid inputs return no errors" (let [result (action-v2.tu/create-rows! table-id [{"name" "Test Product" "price" "123" @@ -705,7 +654,6 @@ "created_at" "2024-03-15T14:30:00"}])] (is (nil? (:errors result))) (is (seq (:outputs result))))) - (testing "Invalid inputs return validation errors" (let [result (action-v2.tu/create-rows! table-id :crowberto 400 [{"name" "Test Product" "price" "not-a-number" @@ -713,12 +661,10 @@ "created_at" "2024-03-15T14:30:00"}])] (is (= {table-id [{:price "Must be an integer" :active "Must be true, false, 0, or 1"}]} (:errors result))))) - (testing "Required field validation" (let [result (action-v2.tu/create-rows! table-id :crowberto 400 [{"name" nil "price" "123"}])] (is (= {table-id [{:name "This field is required"}]} (:errors result))))) - (testing "Multiple rows with mixed validity" (let [result (action-v2.tu/create-rows! table-id :crowberto 400 [{"name" "Valid Product" "price" "100"} diff --git a/enterprise/backend/test/metabase_enterprise/action_v2/coerce_test.clj b/enterprise/backend/test/metabase_enterprise/action_v2/coerce_test.clj index e83172227478..8b057af336ea 100644 --- a/enterprise/backend/test/metabase_enterprise/action_v2/coerce_test.clj +++ b/enterprise/backend/test/metabase_enterprise/action_v2/coerce_test.clj @@ -13,36 +13,28 @@ [{:strategy :Coercion/UNIXSeconds->DateTime :input "2024-03-20T15:30:45Z[UTC]" :output 1710948645} - {:strategy :Coercion/UNIXMilliSeconds->DateTime :input "2024-03-20T15:30:45Z[UTC]" :output 1710948645000} - {:strategy :Coercion/UNIXMicroSeconds->DateTime :input "2024-03-20T15:30:45Z[UTC]" :output 1710948645000000} - {:strategy :Coercion/UNIXNanoSeconds->DateTime :input "2024-03-20T15:30:45Z[UTC]" :output 1710948645000000000} - {:strategy :Coercion/YYYYMMDDHHMMSSString->Temporal :input "2024-03-20T15:30Z[UTC]" ;; Note: seconds set to 00 since the format doesn't preserve seconds :output "20240320153000"} - {:strategy :Coercion/ISO8601->DateTime :input "2024-03-20T15:30:45Z[UTC]" :output "2024-03-20T15:30:45Z[UTC]"} - {:strategy :Coercion/ISO8601->Date :input "2024-03-20T00:00Z[UTC]" :output "2024-03-20"} - {:strategy :Coercion/ISO8601->Time :input "2025-05-01T15:30:45Z[UTC]" :reversed #(str/ends-with? % "T15:30:45Z[UTC]") :output "15:30:45"}]] - (doseq [{:keys [strategy input reversed output]} test-cases] (testing (format "Testing %s conversions" strategy) (let [reversed? (or reversed #{input}) @@ -52,7 +44,6 @@ (is (= output (in input)) (format "Input conversion failed for %s: expected %s, got %s" strategy output (in input)))) - ;; Test output conversion (Database format -> JSON) (testing "Output conversion" (is (reversed? (out output)) @@ -62,7 +53,6 @@ input (str "something like " input)) (out output)))) - ;; Test roundtrip conversion (testing "Roundtrip conversion" (is (reversed? (-> input in out)) @@ -72,7 +62,6 @@ (deftest coercion-fns-static-test (testing "all coercion pair have to have an in and out function" (testing (every? #(and (fn? (:in %)) (fn? (:out %))) coerce/coercion-fns))) - ;; TODO: fix this test by implementing all strategies (let [implemented (set (keys coerce/coercion-fns)) expected-fns (into #{} (filter (comp #{"Coercion"} namespace)) (descendants :Coercion/*)) diff --git a/enterprise/backend/test/metabase_enterprise/action_v2/models/undo_test.clj b/enterprise/backend/test/metabase_enterprise/action_v2/models/undo_test.clj index 886a620c8c49..b282d4abce29 100644 --- a/enterprise/backend/test/metabase_enterprise/action_v2/models/undo_test.clj +++ b/enterprise/backend/test/metabase_enterprise/action_v2/models/undo_test.clj @@ -76,57 +76,46 @@ {:primary-key [:id]}]] (let [user-id (mt/user->id :crowberto) test-scope {:table-id table-id}] - (write-sequence! table-id {:id 1} [[user-id {:name "Snorkmaiden" :favourite_food "pork"}] [user-id {:name "Snorkmaiden" :favourite_food "orc"}] [user-id nil]]) - (is (= [] (table-rows table-id))) - (is (next-batch-num :undo user-id test-scope)) (is (not (next-batch-num :redo user-id test-scope))) (is (= {:outputs [{:op "created", :table-id table-id :row {:id 1, :name "Snorkmaiden", :favourite_food "orc"}}]} (undo-via-api! user-id test-scope))) (is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id))) - (is (next-batch-num :undo user-id test-scope)) (is (next-batch-num :redo user-id test-scope)) (is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 1, :name "Snorkmaiden", :favourite_food "pork"}}]} (undo-via-api! user-id test-scope))) (is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id))) - (is (next-batch-num :undo user-id test-scope)) (is (next-batch-num :redo user-id test-scope)) (is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 1}}]} (undo-via-api! user-id test-scope))) (is (= [] (table-rows table-id))) - (is (not (next-batch-num :undo user-id test-scope))) (is (next-batch-num :redo user-id test-scope)) (is (= "Nothing to do" (undo-via-api! user-id test-scope))) - (is (not (next-batch-num :undo user-id test-scope))) (is (next-batch-num :redo user-id test-scope)) (is (= {:outputs [{:op "created", :table-id table-id :row {:id 1, :name "Snorkmaiden", :favourite_food "pork"}}]} (redo-via-api! user-id test-scope))) (is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id))) - (is (next-batch-num :undo user-id test-scope)) (is (next-batch-num :redo user-id test-scope)) (is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 1, :name "Snorkmaiden", :favourite_food "orc"}}]} (redo-via-api! user-id test-scope))) (is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id))) - (is (next-batch-num :undo user-id test-scope)) (is (next-batch-num :redo user-id test-scope)) (is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 1}}]} (redo-via-api! user-id test-scope))) (is (= [] (table-rows table-id))) - (is (next-batch-num :undo user-id test-scope)) (is (not (next-batch-num :redo user-id test-scope))) (is (= "Nothing to do" (redo-via-api! user-id test-scope))) - (is (next-batch-num :undo user-id test-scope)) (is (not (next-batch-num :redo user-id test-scope))))))))) @@ -144,7 +133,6 @@ (mt/with-temp [:model/User {user-2 :id} {:is_superuser true}] (let [user-1 (mt/user->id :crowberto) test-scope {:table-id table-id}] - ;; NOTE: this test relies on the "conflicts even when different columns changed" semantics ;; If we improve the semantics, we'll need to improve this test! @@ -152,23 +140,18 @@ [user-1 {:name "Moomintroll" :power 9001}] [user-2 {:name "Moominswole" :power 9001}] [user-1 nil]]) - (is (= [] (table-rows table-id))) - (is (next-batch-num :undo user-2 test-scope)) (is (not (next-batch-num :redo user-2 test-scope))) (is (= "Your previous change has a conflict with another edit" (undo-via-api! user-2 test-scope))) - (is (next-batch-num :undo user-1 test-scope)) (is (not (next-batch-num :redo user-1 test-scope))) (is (= {:outputs [{:op "created", :table-id table-id, :row {:id 2, :name "Moominswole", :power 9001}}]} (undo-via-api! user-1 test-scope))) (is (= [[2 "Moominswole" 9001]] (table-rows table-id))) - (is (next-batch-num :undo user-1 test-scope)) (is (next-batch-num :redo user-1 test-scope)) (is (= "Your previous change has a conflict with another edit" (undo-via-api! user-1 test-scope))) - (is (next-batch-num :undo user-1 test-scope)) (is (next-batch-num :redo user-1 test-scope)) (is (next-batch-num :undo user-2 test-scope)) @@ -176,55 +159,45 @@ (is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 9001}}]} (undo-via-api! user-2 test-scope))) (is (= [[2 "Moomintroll" 9001]] (table-rows table-id))) - (is (next-batch-num :undo user-1 test-scope)) (is (next-batch-num :redo user-1 test-scope)) (is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 3}}]} (undo-via-api! user-1 test-scope))) (is (= [[2 "Moomintroll" 3]] (table-rows table-id))) - (is (next-batch-num :undo user-1 test-scope)) (is (next-batch-num :redo user-1 test-scope)) (is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 2}}]} (undo-via-api! user-1 test-scope))) (is (= [] (table-rows table-id))) - (is (not (next-batch-num :undo user-1 test-scope))) (is (next-batch-num :redo user-1 test-scope)) (is (= "Nothing to do" (undo-via-api! user-1 test-scope))) - (is (not (next-batch-num :undo user-1 test-scope))) (is (next-batch-num :redo user-1 test-scope)) (is (= {:outputs [{:op "created", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 3}}]} (redo-via-api! user-1 test-scope))) (is (= [[2 "Moomintroll" 3]] (table-rows table-id))) - (is (next-batch-num :undo user-1 test-scope)) (is (next-batch-num :redo user-1 test-scope)) (is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 9001}}]} (redo-via-api! user-1 test-scope))) (is (= [[2 "Moomintroll" 9001]] (table-rows table-id))) - (is (next-batch-num :undo user-1 test-scope)) (is (next-batch-num :redo user-1 test-scope)) (is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moominswole", :power 9001}}]} (redo-via-api! user-2 test-scope))) (is (= [[2 "Moominswole" 9001]] (table-rows table-id))) - (is (next-batch-num :undo user-1 test-scope)) (is (next-batch-num :redo user-1 test-scope)) (is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 2}}]} (redo-via-api! user-1 test-scope))) (is (= [] (table-rows table-id))) - (is (next-batch-num :undo user-1 test-scope)) (is (not (next-batch-num :redo user-1 test-scope))) (is (= "Nothing to do" (redo-via-api! user-1 test-scope))) - (is (next-batch-num :undo user-2 test-scope)) (is (not (next-batch-num :redo user-2 test-scope))) (is (= "Nothing to do" (redo-via-api! user-2 test-scope))) - (is (next-batch-num :undo user-1 test-scope)) (is (not (next-batch-num :redo user-1 test-scope)))))))))) @@ -238,32 +211,24 @@ {:primary-key [:id]}]] (let [user-id (mt/user->id :crowberto) test-scope {:table-id table-id}] - ;; NOTE: this test relies on the "conflicts even when different columns changed" semantics ;; If we improve the semantics, we'll need to improve this test! (write-sequence! table-id {:id 1} [[user-id {:name "Too-ticky" :status "sitting"}] [user-id {:name "Too-tickley" :status "squirming"}] [user-id nil]]) - (write-sequence! table-id {:id 2} [[user-id {:name "Toffle" :status "uncomfortable"}] [user-id {:name "Toffle" :status "comforted"}] [user-id nil]]) - (action-v2.tu/create-rows! table-id user-id 200 [{:id 1, :name "Too-tickley", :status "squirming"}]) - (action-v2.tu/create-rows! table-id user-id 200 [{:id 2, :name "Toggle", :status "restored"}]) (action-v2.tu/delete-rows! table-id user-id 200 [{:id 2}]) - (is (= [[1 "Too-tickley" "squirming"]] (table-rows table-id))) - (is (nil? (next-batch-num :redo user-id test-scope))) (is (next-batch-num :undo user-id test-scope)) - (undo-via-api! user-id test-scope) (is (= [[1 "Too-tickley" "squirming"] [2 "Toggle" "restored"]] (table-rows table-id))) - (redo-via-api! user-id test-scope) (is (= [[1 "Too-tickley" "squirming"]] (table-rows table-id))))))))) @@ -280,7 +245,6 @@ table-2 [{:id [:int]} {:primary-key [:id]}]] (let [user-1 (mt/user->id :crowberto) user-2 (mt/user->id :rasta)] - (testing "Total rows" (with-redefs [undo/retention-total-rows 17] (dotimes [i 25] @@ -291,10 +255,8 @@ :raw_after (if (even? i) nil {})} {:id 2} {:raw_before (if (odd? i) {} nil) :raw_after (if (odd? i) nil {})}}}))) - (is (= 16 (t2/count :model/Undo))) (is (= 8 (count-batches)))) - (testing "Total batches" (with-redefs [undo/retention-total-batches 15] (dotimes [i 25] @@ -305,10 +267,8 @@ :raw_after (if (even? i) nil {})} {:id 2} {:raw_before (if (odd? i) {} nil) :raw_after (if (odd? i) nil {})}}}))) - (is (= 30 (t2/count :model/Undo))) (is (= 15 (count-batches)))) - (testing "User id" (with-redefs [undo/retention-batches-per-user 5] (dotimes [i 25] @@ -320,12 +280,10 @@ :raw_after (if (even? i) nil {})} {:id 2} {:raw_before (if (odd? i) {} nil) :raw_after (if (odd? i) nil {})}}}))) - (is (= 20 (t2/count :model/Undo))) (is (= 10 (count-batches))) (is (= 5 (count-batches [:= :user_id user-1]))) (is (= 5 (count-batches [:= :user_id user-2])))) - (testing "Scope" (t2/delete! :model/Undo) (with-redefs [undo/retention-batches-per-scope 9] @@ -339,18 +297,15 @@ :raw_after (if (even? i) nil {})} {:id 2} {:raw_before (if (odd? i) {} nil) :raw_after (if (odd? i) nil {})}}}))) - (is (= 32 (t2/count :model/Undo))) (is (= 16 (count-batches))) (is (= 7 (count-batches [:= :table_id table-1]))) (is (= 9 (count-batches [:= :table_id table-2]))))) - (testing "A haphazard mix" (with-redefs [undo/retention-total-rows 17 undo/retention-total-batches 21 undo/retention-batches-per-user 5 undo/retention-batches-per-scope 9] - (dotimes [i 25] ;; just toggle existence (undo/track-change! (if (zero? (mod i 3)) user-1 user-2) @@ -360,7 +315,6 @@ :raw_after (if (even? i) nil {})} {:id 2} {:raw_before (if (odd? i) {} nil) :raw_after (if (odd? i) nil {})}}}))) - (is (= 16 (t2/count :model/Undo))) (is (= 8 (count-batches))) (is (= 3 (count-batches [:= :user_id user-1]))) @@ -377,10 +331,8 @@ {:primary-key [:id]}]] (let [user-id (mt/user->id :crowberto) test-scope {:table-id table-id}] - ;; Create a regular undoable change first (action-v2.tu/create-rows! table-id user-id 200 [{:id 1, :name "Undoable change"}]) - ;; Manually create a non-undoable change using track-change! directly (undo/track-change! user-id @@ -391,17 +343,14 @@ :undoable false}}}) ; mark as non-undoable (is (= [[1 "Undoable change"]] (table-rows table-id))) - ;; Should have batches available to undo (is (next-batch-num :undo user-id test-scope)) (is (not (next-batch-num :redo user-id test-scope))) - ;; Try to undo - should fail because the latest batch has undoable: false (let [before-batch-num (next-batch-num :undo user-id test-scope)] (is (= "Your previous change cannot be undone" (undo-via-api! user-id test-scope))) (testing "batchnum is unchanged" (is (= before-batch-num (next-batch-num :undo user-id test-scope))))) - ;; Table should remain unchanged (is (= [[1 "Undoable change"]] (table-rows table-id))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/action_v2/validation_test.clj b/enterprise/backend/test/metabase_enterprise/action_v2/validation_test.clj index cc6c52f84a82..8ea4286c5b0f 100644 --- a/enterprise/backend/test/metabase_enterprise/action_v2/validation_test.clj +++ b/enterprise/backend/test/metabase_enterprise/action_v2/validation_test.clj @@ -75,7 +75,6 @@ [nil [{"price" "0"}] [float-field]] [nil [{"price" "-123.45"}] [float-field]] [nil [{"price" nil}] [float-field]] - ;; Invalid cases [[{"price" "Must be a number"}] [{"price" "not-a-number"}] [float-field]] [[{"price" "Must be a number"}] [{"price" "12.34.56"}] [float-field]] @@ -83,7 +82,6 @@ [[{"price" "Must be a number"}] [{"price" " "}] [float-field]] [[{"price" "Must be a number"}] [{"price" true}] [float-field]] [[{"price" "This field is required"}] [{"price" nil}] [float-field-required]]]] - (testing (str "Float validation - inputs: " inputs) (is (= expected (validation/validate-inputs fields inputs))))))) @@ -96,7 +94,6 @@ [nil [{"count" "0"}] [integer-field]] [nil [{"count" "-456"}] [integer-field]] [nil [{"count" nil}] [integer-field]] - ;; Invalid cases [[{"count" "Must be an integer"}] [{"count" "123.45"}] [integer-field]] [[{"count" "Must be an integer"}] [{"count" "abc"}] [integer-field]] @@ -105,7 +102,6 @@ [[{"count" "Must be an integer"}] [{"count" "1.0"}] [integer-field]] [[{"count" "Must be an integer"}] [{"count" true}] [integer-field]] [[{"count" "This field is required"}] [{"count" nil}] [integer-field-required]]]] - (testing (str "Integer validation - inputs: " inputs) (is (= expected (validation/validate-inputs fields inputs))))))) @@ -119,14 +115,12 @@ [nil [{"value" 123.45}] [number-field]] [nil [{"value" "-999.99"}] [number-field]] [nil [{"value" nil}] [number-field]] - ;; Invalid cases [[{"value" "Must be a number"}] [{"value" "not-a-number"}] [number-field]] [[{"value" "Must be a number"}] [{"value" "12.34.56"}] [number-field]] [[{"value" "Must be a number"}] [{"value" ""}] [number-field]] [[{"value" "Must be a number"}] [{"value" false}] [number-field]] [[{"value" "This field is required"}] [{"value" nil}] [number-field-required]]]] - (testing (str "Number validation - inputs: " inputs) (is (= expected (validation/validate-inputs fields inputs))))))) @@ -139,14 +133,12 @@ [nil [{"name" " spaces "}] [text-field]] [nil [{"name" "123"}] [text-field]] [nil [{"name" nil}] [text-field]] - ;; Invalid cases [[{"name" "Must be a text string"}] [{"name" 123}] [text-field]] [[{"name" "Must be a text string"}] [{"name" true}] [text-field]] [[{"name" "Must be a text string"}] [{"name" false}] [text-field]] [[{"name" "Must be a text string"}] [{"name" {:key "value"}}] [text-field]] [[{"name" "This field is required"}] [{"name" nil}] [text-field-required]]]] - (testing (str "Text validation - inputs: " inputs) (is (= expected (validation/validate-inputs fields inputs))))))) @@ -163,7 +155,6 @@ [nil [{"active" true}] [boolean-field]] [nil [{"active" false}] [boolean-field]] [nil [{"active" nil}] [boolean-field]] - ;; Invalid cases [[{"active" "Must be true, false, 0, or 1"}] [{"active" "yes"}] [boolean-field]] [[{"active" "Must be true, false, 0, or 1"}] [{"active" "no"}] [boolean-field]] @@ -172,7 +163,6 @@ [[{"active" "Must be true, false, 0, or 1"}] [{"active" 123}] [boolean-field]] [[{"active" "Must be true, false, 0, or 1"}] [{"active" 1}] [boolean-field]] [[{"active" "This field is required"}] [{"active" nil}] [boolean-field-required]]]] - (testing (str "Boolean validation - inputs: " inputs) (is (= expected (validation/validate-inputs fields inputs))))))) @@ -184,7 +174,6 @@ [nil [{"birth_date" "1999-12-31"}] [date-field]] [nil [{"birth_date" "2024-01-01"}] [date-field]] [nil [{"birth_date" nil}] [date-field]] - ;; Invalid cases [[{"birth_date" "Must be a valid date in format YYYY-MM-DD"}] [{"birth_date" "2024-13-15"}] [date-field]] [[{"birth_date" "Must be a valid date in format YYYY-MM-DD"}] [{"birth_date" "2024-3-15"}] [date-field]] @@ -197,7 +186,6 @@ [[{"birth_date" "Must be a valid date in format YYYY-MM-DD"}] [{"birth_date" 123}] [date-field]] [[{"birth_date" "Must be a valid date in format YYYY-MM-DD"}] [{"birth_date" "2024-13-13"}] [date-field]] [[{"birth_date" "This field is required"}] [{"birth_date" nil}] [date-field-required]]]] - (testing (str "Date validation - inputs: " inputs) (is (= expected (validation/validate-inputs fields inputs))))))) @@ -210,7 +198,6 @@ [nil [{"start_time" "00:00:00"}] [time-field]] [nil [{"start_time" "23:59:59"}] [time-field]] [nil [{"start_time" nil}] [time-field]] - ;; Invalid cases [[{"start_time" "Must be a valid time in format HH:mm:ss"}] [{"start_time" "2:30:00"}] [time-field]] [[{"start_time" "Must be a valid time in format HH:mm:ss"}] [{"start_time" "14:3:00"}] [time-field]] @@ -220,7 +207,6 @@ [[{"start_time" "Must be a valid time in format HH:mm:ss"}] [{"start_time" ""}] [time-field]] [[{"start_time" "Must be a valid time in format HH:mm:ss"}] [{"start_time" 143000}] [time-field]] [[{"start_time" "This field is required"}] [{"start_time" nil}] [time-field-required]]]] - (testing (str "Time validation - inputs: " inputs) (is (= expected (validation/validate-inputs fields inputs))))))) @@ -234,7 +220,6 @@ [nil [{"created_at" "2024-03-15T14:30:00"}] [datetime-field]] [nil [{"created_at" "2024-03-15T14:30"}] [datetime-field]] [nil [{"created_at" nil}] [datetime-field]] - ;; Invalid cases [[{"created_at" "Must be a valid datetime in format YYYY-MM-DDTHH:mm:ss or YYYY-MM-DDTHH:mm:ssZ"}] [{"created_at" "2024-03-15"}] [datetime-field]] @@ -252,7 +237,6 @@ [{"created_at" 1234567890}] [datetime-field]] [[{"created_at" "This field is required"}] [{"created_at" nil}] [datetime-field-required]]]] - (testing (str "DateTime validation - inputs: " inputs) (is (= expected (validation/validate-inputs fields inputs))))))) @@ -265,7 +249,6 @@ [nil [{"big_count" "0"}] [biginteger-field]] [nil [{"big_count" "-456"}] [biginteger-field]] [nil [{"big_count" nil}] [biginteger-field]] - ;; Valid cases - large integers that exceed Long.MAX_VALUE [nil [{"big_count" "9223372036854775808"}] [biginteger-field]] ; Long.MAX_VALUE + 1 [nil [{"big_count" "92233720368547758070"}] [biginteger-field]] ; Much larger @@ -280,7 +263,6 @@ [[{"big_count" "Must be an integer"}] [{"big_count" "1.0"}] [biginteger-field]] [[{"big_count" "Must be an integer"}] [{"big_count" true}] [biginteger-field]] [[{"big_count" "This field is required"}] [{"big_count" nil}] [biginteger-field-required]]]] - (testing (str "BigInteger validation - inputs: " inputs) (is (= expected (validation/validate-inputs fields inputs))))))) @@ -294,12 +276,10 @@ [nil [{"precise_value" "0"}] [decimal-field]] [nil [{"precise_value" "-123.45"}] [decimal-field]] [nil [{"precise_value" nil}] [decimal-field]] - ;; Valid cases - high precision decimals that exceed Double precision [nil [{"precise_value" "123.456789012345678901234567890"}] [decimal-field]] [nil [{"precise_value" "99999999999999999999.99999999999999999999"}] [decimal-field]] [nil [{"precise_value" "-0.000000000000000000000000000001"}] [decimal-field]] - ;; Invalid cases [[{"precise_value" "Must be a number"}] [{"precise_value" "not-a-number"}] [decimal-field]] [[{"precise_value" "Must be a number"}] [{"precise_value" "12.34.56"}] [decimal-field]] @@ -307,7 +287,6 @@ [[{"precise_value" "Must be a number"}] [{"precise_value" " "}] [decimal-field]] [[{"precise_value" "Must be a number"}] [{"precise_value" true}] [decimal-field]] [[{"precise_value" "This field is required"}] [{"precise_value" nil}] [decimal-field-required]]]] - (testing (str "Decimal validation - inputs: " inputs) (is (= expected (validation/validate-inputs fields inputs))))))) @@ -318,20 +297,17 @@ (validation/validate-inputs [text-field integer-field boolean-field] [{"name" "John" "count" "30" "active" "true"}])))) - (testing "Multiple errors in single row" (is (= [{"name" "This field is required" "count" "Must be an integer" "active" "Must be true, false, 0, or 1"}] (validation/validate-inputs [text-field-required integer-field boolean-field] [{"name" nil "count" "thirty" "active" "maybe"}])))) - (testing "Multiple rows" (is (= [{"count" "Must be an integer"} {"count" "Must be an integer"}] (validation/validate-inputs [text-field integer-field] [{"name" "John" "count" "thirty"} {"name" "Jane" "count" "twenty-five"}])))) - (testing "Unknown fields are ignored" (is (= nil (validation/validate-inputs diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/api/logs_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/api/logs_test.clj index 3deb385ee75e..cd9e35618bfc 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_config/api/logs_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/api/logs_test.clj @@ -41,7 +41,6 @@ (filter #(#{user-id} (:executor_id %))) (filter #((set (map :id [qe-a qe-b])) (:id %))) (map #(select-keys % [:started_at :id])))))))))) - (testing "permission tests" (testing "require admins" (mt/with-premium-features #{:audit-app} diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/file/api_keys_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/file/api_keys_test.clj index 013815d5abcf..8f2df42b50b6 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_config/file/api_keys_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/file/api_keys_test.clj @@ -140,7 +140,6 @@ (is (api-key-exists? "Duplicate Name Key")) (let [original-key (t2/select-one :model/ApiKey :name "Duplicate Name Key") original-key-prefix (:key_prefix original-key)] - ;; Now attempt to create another key with the same name but different prefix (binding [config.file/*config* {:version 1 :config {:api-keys [{:name "Duplicate Name Key" diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj index 44f7bd032e99..bd52b9be5989 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/file/settings_test.clj @@ -28,7 +28,6 @@ (testing "Wrong value type should throw an error." (binding [advanced-config.file/*config* {:version 1 :config {:settings {:config-from-file-settings-test-setting 1000}}}] - (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid input: .*" diff --git a/enterprise/backend/test/metabase_enterprise/advanced_config/file/users_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_config/file/users_test.clj index f8a5fcf5cc89..125c95df80ee 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_config/file/users_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_config/file/users_test.clj @@ -33,7 +33,6 @@ :email "cam+config-file-test@metabase.com"))) (is (= 1 (t2/count :model/User :email "cam+config-file-test@metabase.com")))) - (testing "upsert if User already exists, with merged login attributes" (let [hashed-password (fn [] (t2/select-one-fn :password :model/User :email "cam+config-file-test@metabase.com")) salt (fn [] (t2/select-one-fn :password_salt :model/User :email "cam+config-file-test@metabase.com")) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/application_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/application_test.clj index 66474e7b3720..b4c8702723a7 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/application_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/application_test.clj @@ -11,12 +11,10 @@ (mt/with-premium-features #{} (testing "Should require a token with `:advanced-permissions`" (mt/assert-has-premium-feature-error "Advanced Permissions" (mt/user-http-request :crowberto :get 402 "ee/advanced-permissions/application/graph")))) - (mt/with-premium-features #{:advanced-permissions} (testing "have to be a superuser" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "ee/advanced-permissions/application/graph")))) - (testing "return application permissions for groups that has application permissions" (let [graph (mt/user-http-request :crowberto :get 200 "ee/advanced-permissions/application/graph") groups (:groups graph)] @@ -37,22 +35,18 @@ (let [current-graph (mt/with-premium-features #{:advanced-permissions} (mt/user-http-request :crowberto :get 200 "ee/advanced-permissions/application/graph")) new-graph (assoc-in current-graph [:groups group-id :setting] "yes")] - (mt/with-premium-features #{} (testing "Should require a token with `:advanced-permissions`" (mt/assert-has-premium-feature-error "Advanced Permissions" (mt/user-http-request :crowberto :put 402 "ee/advanced-permissions/application/graph" new-graph)))) - (mt/with-premium-features #{:advanced-permissions} (testing "have to be a superuser" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 "ee/advanced-permissions/application/graph" new-graph)))) - (testing "failed when revision is mismatched" (is (= "Looks like someone else edited the permissions and your data is out of date. Please fetch new data and try again." (mt/user-http-request :crowberto :put 409 "ee/advanced-permissions/application/graph" (assoc new-graph :revision (inc (:revision new-graph))))))) - (testing "successfully update application permissions" (is (partial= {(:id (perms-group/admin)) {:monitoring "yes" @@ -63,13 +57,11 @@ :setting "yes" :subscription "no"}} (:groups (mt/user-http-request :crowberto :put 200 "ee/advanced-permissions/application/graph" new-graph))))) - (testing "omits graph in response when skip-graph=true" (let [result (mt/user-http-request :crowberto :put 200 "ee/advanced-permissions/application/graph?skip-graph=true" (a-perms/graph))] (is (int? (:revision result))) (is (nil? (:groups result))))) - (testing "omits revision ID check when force=true" (let [result (mt/user-http-request :crowberto :put 200 "ee/advanced-permissions/application/graph?force=true" (-> (a-perms/graph) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/channel_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/channel_test.clj index 30d8430aaae3..5d68e820b016 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/channel_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/channel_test.clj @@ -39,10 +39,8 @@ (mt/with-temp [:model/Channel {id :id} notification.tu/default-can-connect-channel] (testing (format "GET /api/channel/:id with %s user" (mt/user-descriptor user)) (is (= include-details? (contains? (mt/user-http-request user :get 200 (str "channel/" id)) :details)))) - (testing (format "GET /api/channel with %s user" (mt/user-descriptor user)) (is (every? #(= % include-details?) (map #(contains? % :details) (mt/user-http-request user :get 200 "channel/")))))))] - (testing "if `advanced-permissions` is disabled, require admins" (mt/with-premium-features #{} (create-channel user 403) @@ -53,7 +51,6 @@ (update-channel :crowberto 200) (test-channel :crowberto 200) (include-details :crowberto true))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "still fail if user's group doesn't have `setting` permission" @@ -65,7 +62,6 @@ (update-channel :crowberto 200) (test-channel :crowberto 200) (include-details :crowberto true)) - (testing "succeed if user's group has `setting` permission" (perms/grant-application-permissions! group :setting) (create-channel user 200) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/group_manager_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/group_manager_test.clj index 67935190339c..f84ae8f07e81 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/group_manager_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/group_manager_test.clj @@ -20,16 +20,13 @@ (letfn [(get-groups [user status] (testing (format ", get groups with %s user" (mt/user-descriptor user)) (mt/user-http-request user :get status "permissions/group"))) - (get-one-group [user status group] (testing (format ", get one group with %s user" (mt/user-descriptor user)) (mt/user-http-request user :get status (format "permissions/group/%d" (:id group))))) - (update-group [user status group] (testing (format ", update group with %s user" (mt/user-descriptor user)) (let [new-name (mt/random-name)] (mt/user-http-request user :put status (format "permissions/group/%d" (:id group)) {:name new-name})))) - (delete-group [user status group-manager?] (testing (format ", delete group with %s user" (mt/user-descriptor user)) (let [user-id (u/the-id (if (keyword? user) (mt/fetch-user user) user))] @@ -43,7 +40,6 @@ (mt/user-http-request user :delete status (format "permissions/group/%d" group-id))))))] - (testing "if `advanced-permissions` is disabled, require admins" (mt/with-premium-features #{} (get-groups user 403) @@ -54,7 +50,6 @@ (get-one-group :crowberto 200 group) (update-group :crowberto 200 group) (delete-group :crowberto 204 false))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions :library} (testing "still fails if user is not a manager" @@ -66,7 +61,6 @@ (get-one-group :crowberto 200 group) (update-group :crowberto 200 group) (delete-group :crowberto 204 false)) - (testing "succeed if users access group that they are manager of" (t2/update! :model/PermissionsGroupMembership {:user_id (:id user) :group_id (:id group)} @@ -74,11 +68,9 @@ (testing "non-admin user can only view groups that are manager of" (is (= #{(:id group)} (set (map :id (get-groups user 200)))))) - (get-one-group user 200 group) (update-group user 200 group) (delete-group user 204 true) - (testing "admins could view all groups" (is (= (t2/select-fn-set :name :model/PermissionsGroup :is_tenant_group false) (set (map :name (get-groups :crowberto 200))))))))))))) @@ -144,7 +136,6 @@ (update-membership! :crowberto 402 group false) (delete-membership! :crowberto 204 group) (clear-memberships! :crowberto 204 group)))) - ;; Use different groups for each block since `clear-memberships!` is destructive (mt/with-user-in-groups [group {:name "New Group"} @@ -162,7 +153,6 @@ (update-membership! :crowberto 200 group false) (delete-membership! :crowberto 204 group) (clear-memberships! :crowberto 204 group)) - (mt/with-user-in-groups [group-2 {:name "New Group 2"} user-2 [group-2]] @@ -181,12 +171,10 @@ (mt/with-user-in-groups [group {:name "New Group"} user [group]] - (testing "if `advanced-permissions` is disabled" (mt/with-premium-features #{} (testing "fail when try to set is_group_manager=true" (add-membership! :crowberto 402 group true)))) - (testing "if advanced-permissions is enabled, " (mt/with-premium-features #{:advanced-permissions} (testing "succeed if users access group that they are manager of," @@ -196,10 +184,8 @@ (testing "can set is_group_manager=true" (add-membership! :crowberto 200 group true) (add-membership! user 200 group true)) - (testing "non-admin user can only view groups that are manager of" (is (= #{(:id group)} (membership->groups-ids (get-membership user 200)))))) - (testing "admin cannot be group manager" (mt/with-temp [:model/User new-user {:is_superuser true} :model/PermissionsGroupMembership _ {:user_id (:id new-user) @@ -210,7 +196,6 @@ {:group_id (:id group) :user_id (:id new-user) :is_group_manager true}))))) - (testing "Admin can view all groups with members" (is (= (t2/select-fn-set :group_id :model/PermissionsGroupMembership) (membership->groups-ids (get-membership :crowberto 200)))))))))) @@ -227,7 +212,6 @@ (mt/with-premium-features #{} (get-users user 403) (get-users :crowberto 200))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "requires Group Manager or admins" @@ -277,18 +261,15 @@ (testing (format "- get user with %s user" (mt/user-descriptor user)) (mt/with-temp [:model/User new-user] (mt/user-http-request req-user :get status (format "user/%d" (:id new-user))))))] - (testing "if `advanced-permissions` is disabled, require admins" (mt/with-premium-features #{} (get-user user 403) (get-user :crowberto 200))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "requires Group Manager or admins" (get-user user 403) (get-user :crowberto 200)) - (testing "succeed if users is a group manager and returns additional fields" (t2/update! :model/PermissionsGroupMembership {:user_id (:id user) :group_id (:id group)} @@ -321,7 +302,6 @@ (testing (format "- add user to group with %s user without group_manager set" (mt/user-descriptor user)) (mt/user-http-request req-user :put status (format "user/%d" (:id user-to-update)) {:user_group_memberships (map #(dissoc % :is_group_manager) new-user-group-membership)}))) - (binding [perms-group-membership/*allow-direct-deletion* true] (t2/delete! :model/PermissionsGroupMembership :user_id (:id user-to-update) @@ -352,21 +332,18 @@ (update-user-firstname! :crowberto 200) (add-user-to-group! :crowberto 200 group) (remove-user-from-group! :crowberto 200 group))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "Group Managers" (t2/update! :model/PermissionsGroupMembership {:user_id (:id user) :group_id (:id group)} {:is_group_manager true}) - (testing "Can't edit users' info" (let [current-user-first-name (t2/select-one-fn :first_name :model/User :id (:id user))] (update-user-firstname! user 200) ;; call still success but first name won't get updated (is (= current-user-first-name (t2/select-one-fn :first_name :model/User :id (:id user)))))) - (testing "Can add/remove user to groups they're manager of" (is (= (set [{:id (:id (perms-group/all-users)) :is_group_manager false} @@ -376,7 +353,6 @@ (is (= (set [{:id (:id (perms-group/all-users)) :is_group_manager false}]) (set (:user_group_memberships (remove-user-from-group! user 200 group)))))) - (testing "Can't remove users from group they're not manager of" (mt/with-temp [:model/PermissionsGroup random-group] (add-user-to-group! user 403 random-group) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/monitoring_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/monitoring_test.clj index f5f04ae09273..34563e819705 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/monitoring_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/monitoring_test.clj @@ -26,14 +26,12 @@ (get-tasks :crowberto 200) (get-single-task :crowberto 200) (get-task-info :crowberto 200))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "still fail if user's group doesn't have `monitoring` permission" (get-tasks user 403) (get-single-task user 403) (get-task-info user 403)) - (testing "allowed if user's group has `monitoring` permission" (perms/grant-application-permissions! group :monitoring) (get-tasks user 200) @@ -122,19 +120,16 @@ (letfn [(fetch-persisted-info [user status] (testing (format "persist with %s user" (mt/user-descriptor user)) (mt/user-http-request user :get status "persist")))] - (testing "if `advanced-permissions` is disabled, require admins," (fetch-persisted-info :crowberto 200) (fetch-persisted-info user 403) (fetch-persisted-info :rasta 403)) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "still fail if user's group doesn't have `setting` permission," (fetch-persisted-info :crowberto 200) (fetch-persisted-info user 403) (fetch-persisted-info :rasta 403)) - (testing "succeed if user's group has `monitoring` permission," (perms/grant-application-permissions! group :monitoring) (fetch-persisted-info :crowberto 200) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj index 5de9d71c6750..f1521a3a7154 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj @@ -32,13 +32,11 @@ (delete-email-setting! [user status] (testing (format "delete email setting with %s user" (mt/user-descriptor user)) (mt/user-http-request user :delete status "email"))) - (send-test-email! [user status] (mt/with-temporary-setting-values [email-from-address "notifications@metabase.com"] (mt/with-fake-inbox (testing (format "send test email with %s user" (mt/user-descriptor user)) (mt/user-http-request user :post status "email/test")))))] - (testing "if `advanced-permissions` is disabled, require admins" (mt/with-premium-features #{} (set-email-setting! user 403) @@ -47,7 +45,6 @@ (set-email-setting! :crowberto 200) (delete-email-setting! :crowberto 204) (send-test-email! :crowberto 200))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "still fail if user's group doesn't have `setting` permission" @@ -57,7 +54,6 @@ (set-email-setting! :crowberto 200) (delete-email-setting! :crowberto 204) (send-test-email! :crowberto 200)) - (testing "succeed if user's group has `setting` permission" (perms/grant-application-permissions! group :setting) (set-email-setting! user 200) @@ -77,19 +73,16 @@ slack/refresh-channels-and-usernames-when-needed! (constantly true)] (mt/with-temporary-setting-values [slack-app-token nil] (mt/user-http-request user :put status "slack/settings" {:slack-app-token "fake-token"}))))) - (get-manifest [user status] (testing (format "get slack manifest %s user" (mt/user-descriptor user)) (mt/with-temporary-setting-values [site-url "http://localhost:3000"] (mt/user-http-request user :get status "slack/manifest"))))] - (testing "if `advanced-permissions` is disabled, require admins" (mt/with-premium-features #{} (set-slack-settings! user 403) (get-manifest user 403) (set-slack-settings! :crowberto 200) (get-manifest :crowberto 200))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "still fail if user's group doesn't have `setting` permission" @@ -97,7 +90,6 @@ (get-manifest user 403) (set-slack-settings! :crowberto 200) (get-manifest :crowberto 200)) - (testing "succeed if user's group has `setting` permission" (perms/grant-application-permissions! group :setting) (set-slack-settings! user 200) @@ -115,18 +107,15 @@ (geojson-test/with-geojson-mocks (mt/user-http-request user :get status "geojson" :url geojson-test/test-geojson-url))))] - (testing "if `advanced-permissions` is disabled, require admins" (mt/with-premium-features #{} (get-geojson user 403) (get-geojson :crowberto 200))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "still fail if user's group doesn't have `setting` permission" (get-geojson user 403) (get-geojson :crowberto 200)) - (testing "succeed if user's group has `setting` permission" (perms/grant-application-permissions! group :setting) (get-geojson user 200) @@ -140,18 +129,15 @@ (letfn [(get-permission-groups [user status] (testing (format "get permission groups with %s user" (mt/user-descriptor user)) (mt/user-http-request user :get status "permissions/group")))] - (testing "if `advanced-permissions` is disabled, require admins" (mt/with-premium-features #{} (get-permission-groups user 403) (get-permission-groups :crowberto 200))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "still fail if user's group doesn't have `setting` permission" (get-permission-groups user 403) (get-permission-groups :crowberto 200)) - (testing "succeed if user's group has `setting` permission" (perms/grant-application-permissions! group :setting) (get-permission-groups user 200) @@ -167,18 +153,15 @@ (letfn [(get-public-dashboards [user status] (testing (format "get public dashboards with %s user" (mt/user-descriptor user)) (mt/user-http-request user :get status "dashboard/public"))) - (get-embeddable-dashboards [user status] (testing (format "get embeddable dashboards with %s user" (mt/user-descriptor user)) (mt/with-temp [:model/Dashboard _ {:enable_embedding true}] (mt/user-http-request user :get status "dashboard/embeddable")))) - (delete-public-dashboard! [user status] (testing (format "delete public dashboard with %s user" (mt/user-descriptor user)) (mt/with-temp [:model/Dashboard {dashboard-id :id} {:public_uuid (str (random-uuid)) :made_public_by_id (mt/user->id :crowberto)}] (mt/user-http-request user :delete status (format "dashboard/%d/public_link" dashboard-id)))))] - (testing "if `advanced-permissions` is disabled, require admins," (mt/with-premium-features #{} (get-public-dashboards user 403) @@ -186,7 +169,6 @@ (delete-public-dashboard! user 403) (get-embeddable-dashboards :crowberto 200) (delete-public-dashboard! :crowberto 204))) - (testing "if `advanced-permissions` is enabled," (mt/with-premium-features #{:advanced-permissions} (testing "still fail if user's group doesn't have `setting` permission" @@ -195,7 +177,6 @@ (delete-public-dashboard! user 403) (get-public-dashboards :crowberto 200) (delete-public-dashboard! :crowberto 204)) - (testing "succeed if user's group has `setting` permission," (perms/grant-application-permissions! group :setting) (get-public-dashboards user 200) @@ -213,7 +194,6 @@ (letfn [(get-public-actions [user status] (testing (format "get public actions with %s user" (mt/user-descriptor user)) (mt/user-http-request user :get status "action/public"))) - (delete-public-action! [user status] (testing (format "delete public action with %s user" (mt/user-descriptor user)) (mt/with-actions [{:keys [action-id]} {:public_uuid (str (random-uuid)) @@ -246,18 +226,15 @@ (letfn [(get-public-cards [user status] (testing (format "get public cards with %s user" (mt/user-descriptor user)) (mt/user-http-request user :get status "card/public"))) - (get-embeddable-cards [user status] (testing (format "get embeddable cards with %s user" (mt/user-descriptor user)) (mt/with-temp [:model/Card _ {:enable_embedding true}] (mt/user-http-request user :get status "card/embeddable")))) - (delete-public-card! [user status] (testing (format "delete public card with %s user" (mt/user-descriptor user)) (mt/with-temp [:model/Card {card-id :id} {:public_uuid (str (random-uuid)) :made_public_by_id (mt/user->id :crowberto)}] (mt/user-http-request user :delete status (format "card/%d/public_link" card-id)))))] - (testing "if `advanced-permissions` is disabled, require admins," (mt/with-premium-features #{} (get-public-cards user 403) @@ -266,7 +243,6 @@ (get-public-cards :crowberto 200) (get-embeddable-cards :crowberto 200) (delete-public-card! :crowberto 204))) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "still fail if user's group doesn't have `setting` permission," @@ -276,7 +252,6 @@ (get-public-cards :crowberto 200) (get-embeddable-cards :crowberto 200) (delete-public-card! :crowberto 204)) - (testing "succeed if user's group has `setting` permission," (perms/grant-application-permissions! group :setting) (get-public-cards user 200) @@ -299,45 +274,36 @@ (mt/user-http-request user :post status "persist/set-refresh-schedule" {"cron" "0 0 0/1 * * ? *"})))] - (testing "if `advanced-permissions` is disabled, require admins," (enable-persist! :crowberto 204) (enable-persist! user 403) (enable-persist! :rasta 403) - (disable-persist! :crowberto 204) (disable-persist! user 403) (disable-persist! :rasta 403) - (set-interval! :crowberto 204) (set-interval! user 403) (set-interval! :rasta 403)) - (testing "if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (testing "still fail if user's group doesn't have `setting` permission," (enable-persist! :crowberto 204) (enable-persist! user 403) (enable-persist! :rasta 403) - (disable-persist! :crowberto 204) (disable-persist! user 403) (disable-persist! :rasta 403) - (set-interval! :crowberto 204) (set-interval! user 403) (set-interval! :rasta 403)) - (testing "succeed if user's group has `setting` permission," (perms/grant-application-permissions! group :setting) (enable-persist! :crowberto 204) (enable-persist! user 204) (enable-persist! :rasta 403) - (disable-persist! :crowberto 204) (disable-persist! user 204) (disable-persist! :rasta 403) - (set-interval! :crowberto 204) (set-interval! user 204) (set-interval! :rasta 403)))))))) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/subscription_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/subscription_test.clj index 120145c57c40..ade3b3c2e6cb 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/subscription_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/subscription_test.clj @@ -57,13 +57,11 @@ (create-pulse 200) (update-pulse 200) (get-form 200))) - (testing "should fail if `advanced-permissions` is enabled" (mt/with-premium-features #{:advanced-permissions} (create-pulse 403) (update-pulse 403) (get-form 403)))) - (testing "User's group with subscription permission" (perms/grant-application-permissions! group :subscription) (mt/with-premium-features #{:advanced-permissions} diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/common_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/common_test.clj index fee20a1ce181..89466fa21d62 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/common_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/common_test.clj @@ -34,7 +34,6 @@ :is_group_manager false :can_access_db_details true} (user-permissions :crowberto)))) - (testing "non-admin users should only have subscriptions enabled by default" (is (=? {:can_access_setting false :can_access_subscription true @@ -43,7 +42,6 @@ :is_group_manager false :can_access_db_details false} (user-permissions :rasta)))) - (testing "can_access_data_model is true if a user has any data model perms" (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 @@ -54,7 +52,6 @@ id-4 :none}}}}} (is (partial= {:can_access_data_model true} (user-permissions :rasta)))))) - (testing "can_access_db_details is true if a user has any details perms" (mt/with-all-users-data-perms-graph! {(mt/id) {:details :yes}} (is (partial= {:can_access_db_details true} @@ -90,19 +87,16 @@ (testing "A new database defaults to `:unrestricted` if no other perms are set" (mt/with-temp [:model/Database {db-id-2 :id} {}] (is (= :unrestricted (perm-value db-id-2))))) - (testing "A new database defaults to `:blocked` if the group has `:blocked` for any other database" (data-perms/set-database-permission! group-id db-id :perms/view-data :blocked) (mt/with-temp [:model/Database {db-id-2 :id} {}] (is (= :blocked (perm-value db-id-2))))) - (testing "A new database defaults to `:blocked` if the group has any connection impersonation" (data-perms/set-database-permission! group-id db-id :perms/view-data :unrestricted) (mt/with-temp [:model/ConnectionImpersonation _ {:group_id group-id :db_id db-id} :model/Database {db-id-2 :id} {}] (is (= :blocked (perm-value db-id-2))))) - (testing "A new database defaults to `:blocked` if the group has a sandbox for any table" (mt/with-temp [:model/Table {table-id :id} {:db_id db-id} :model/Sandbox _ {:group_id group-id @@ -129,7 +123,6 @@ ;; Check that no DB-level perm is set (is (nil? (perm-value nil))) (is (= :blocked (perm-value table-id-3))))) - (testing "A new table defaults to `:blocked` if the group has a sandbox for any existing table" (data-perms/set-table-permission! group-id table-id-1 :perms/view-data :unrestricted) (mt/with-temp [:model/Sandbox _ {:group_id group-id @@ -146,18 +139,15 @@ (testing "A new group defaults to `:unrestricted` for a DB if All Users has `:unrestricted`" (data-perms/set-database-permission! all-users-group-id db-id :perms/view-data :unrestricted) (is (= {db-id :unrestricted} (advanced-permissions.common/new-group-view-data-permission-levels [db-id])))) - (testing "A new group defaults to `:blocked` for a DB if All Users has `:blocked`" (data-perms/set-database-permission! all-users-group-id db-id :perms/view-data :blocked) (is (= {db-id :blocked} (advanced-permissions.common/new-group-view-data-permission-levels [db-id])))) - (testing "A new group defaults to `:blocked` if All Users has any connection impersonation" (data-perms/set-database-permission! all-users-group-id db-id :perms/view-data :unrestricted) (advanced-perms.api.tu/with-impersonations! {:impersonations [{:db-id db-id :attribute "impersonation_attr" :attributes {"impersonation_attr" "impersonation_role"}}]} (is (= {db-id :blocked} (advanced-permissions.common/new-group-view-data-permission-levels [db-id]))))) - (testing "A new database defaults to `:blocked` if All Users group has any sandbox" (data-perms/set-database-permission! all-users-group-id db-id :perms/view-data :unrestricted) (mt/with-temp [:model/Card {card-id :id} {} @@ -185,14 +175,12 @@ :create-queries :query-builder-and-native :data-model {:schemas :all}}} (is (partial= {:id (mt/id)} (get-test-db))))) - (testing "A non-admin cannot fetch a DB for which they do not have data model perms if include_editable_data_model=true" (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted :create-queries :query-builder-and-native :data-model {:schemas :none}}} (is (= nil (get-test-db))))) - (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 :query-builder-and-native @@ -203,7 +191,6 @@ (testing "If a non-admin has data model perms for a single table in a DB, the DB is returned when listing all DBs" (is (partial= {:id (mt/id)} (get-test-db)))) - (testing "if include=tables, only tables with data model perms are included" (is (= [id-1] (->> (get-test-db "database?include_editable_data_model=true&include=tables") :tables @@ -220,7 +207,6 @@ (testing "Sanity check: a non-admin can fetch a DB when they have 'manage' access" (mt/with-all-users-data-perms-graph! {(mt/id) {:details :yes}} (is (partial= {:id (mt/id)} (get-test-db))))) - (testing "A non-admin cannot fetch a DB for which they do not not have 'manage' access" (mt/with-all-users-data-perms-graph! {(mt/id) {:details :no}} (is (= nil (get-test-db))))))))) @@ -232,7 +218,6 @@ :create-queries :query-builder-and-native :data-model {:schemas :none}}} (mt/user-http-request :rasta :get 403 (format "database/%d?include_editable_data_model=true" (mt/id))))) - (testing "A non-admin with only data model perms for a DB can fetch the DB when include_editable_data_model=true" (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted :create-queries :no @@ -254,7 +239,6 @@ (format "database/%d/metadata?include_editable_data_model=true" (mt/id))) :tables)] (is (= [id-1] (map :id tables)))))) - (testing "A user with data model perms can still fetch a DB name and tables if they have block perms for a DB" (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 :blocked @@ -278,7 +262,6 @@ :create-queries :query-builder-and-native :data-model {:schemas :none}}} (mt/user-http-request :rasta :get 403 (format "database/%d/idfields?include_editable_data_model=true" (mt/id))))) - (testing "A non-admin with only data model perms for a DB can fetch id fields when include_editable_data_model=true" (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted :create-queries :no @@ -302,7 +285,6 @@ (is (= ["t1" "t3"] (map :name (mt/user-http-request :rasta :get 200 (format "database/%d/schema/%s" db-id "schema1") :include_editable_data_model true))))))) - (testing "If include_editable_data_model=true and a non-admin does not have data model perms, it should respond with a 404" (mt/with-all-users-data-perms-graph! {db-id {:view-data :blocked @@ -311,7 +293,6 @@ (is (= "Not found." (mt/user-http-request :rasta :get 404 (format "database/%d/schema/%s" db-id "schema1") :include_editable_data_model true))))) - (testing "If include_editable_data_model=true and a non-admin has data model perms for a single table in a schema, the table is returned" (mt/with-all-users-data-perms-graph! {db-id {:view-data :blocked @@ -338,7 +319,6 @@ (is (= ["t1" "t3"] (map :name (mt/user-http-request :rasta :get 200 (format "database/%d/schema/" db-id) :include_editable_data_model true)))))) - (testing "If include_editable_data_model=true and a non-admin does not have data model perms, it should respond with a 404" (mt/with-all-users-data-perms-graph! {db-id {:view-data :blocked @@ -347,7 +327,6 @@ (is (= "Not found." (mt/user-http-request :rasta :get 404 (format "database/%d/schema/" db-id) :include_editable_data_model true))))) - (testing "If include_editable_data_model=true and a non-admin has data model perms for a single table in an empty string schema, it should return the table" (mt/with-all-users-data-perms-graph! {db-id {:view-data :blocked @@ -546,42 +525,32 @@ (mt/with-all-users-data-perms-graph! {db-id {:data-model {:schemas {schema {table-id :all}}}}} (mt/with-premium-features #{} (mt/user-http-request :rasta :put 403 endpoint {:name "Field Test 4"})))) - (testing "a non-admin cannot update field metadata if they have no data model permissions for the DB" (mt/with-all-users-data-perms-graph! {db-id {:data-model {:schemas :none}}} (mt/user-http-request :rasta :put 403 endpoint {:name "Field Test 2"}))) - (testing "a non-admin cannot update field metadata if they only have data model permissions for other schemas" (mt/with-all-users-data-perms-graph! {db-id {:data-model {:schemas {schema :none "different schema" :all}}}} - (mt/user-http-request :rasta :put 403 endpoint {:name "Field Test 2"}))) - (testing "a non-admin cannot update field metadata if they only have data model permissions for other tables" (mt/with-all-users-data-perms-graph! {db-id {:data-model {:schemas {schema {table-id :none table-id-2 :all}}}}} (mt/user-http-request :rasta :put 403 endpoint {:name "Field Test 2"}))) - (testing "a non-admin can update field metadata if they have data model perms for the DB" (mt/with-all-users-data-perms-graph! {db-id {:data-model {:schemas :all}}} (mt/user-http-request :rasta :put 200 endpoint {:name "Field Test 2"}))) - (testing "a non-admin can update field metadata if they have data model perms for the schema" (mt/with-all-users-data-perms-graph! {db-id {:data-model {:schemas {schema :all}}}} (mt/user-http-request :rasta :put 200 endpoint {:name "Field Test 3"}))) - (testing "a non-admin can update field metadata if they have data model perms for the table" (mt/with-all-users-data-perms-graph! {db-id {:data-model {:schemas {schema {table-id :all}}}}} (mt/user-http-request :rasta :put 200 endpoint {:name "Field Test 3"}))))) - (testing "POST /api/field/:id/rescan_values" (testing "A non-admin can trigger a rescan of field values if they have data model perms for the table" (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {schema {table-id :none}}}}} (mt/user-http-request :rasta :post 403 (format "field/%d/rescan_values" field-id))) - (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {schema {table-id :all}}}}} (mt/user-http-request :rasta :post 200 (format "field/%d/rescan_values" field-id)))) - (testing "A non-admin with no data access can trigger a re-scan of field values if they have data model perms" (t2/delete! :model/FieldValues :field_id (mt/id :venues :price)) (is (= nil (t2/select-one-fn :values :model/FieldValues, :field_id (mt/id :venues :price)))) @@ -590,15 +559,12 @@ :data-model {:schemas {"PUBLIC" {(mt/id :venues) :all}}}}} (mt/user-http-request :rasta :post 200 (format "field/%d/rescan_values" (mt/id :venues :price)))) (is (= [1 2 3 4] (t2/select-one-fn :values :model/FieldValues, :field_id (mt/id :venues :price)))))) - (testing "POST /api/field/:id/discard_values" (testing "A non-admin can discard field values if they have data model perms for the table" (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {schema {table-id :none}}}}} (mt/user-http-request :rasta :post 403 (format "field/%d/discard_values" field-id))) - (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {schema {table-id :all}}}}} (mt/user-http-request :rasta :post 200 (format "field/%d/discard_values" field-id)))) - (testing "A non-admin with no data access can discard field values if they have data model perms" (is (= [1 2 3 4] (t2/select-one-fn :values :model/FieldValues, :field_id (mt/id :venues :price)))) (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :blocked @@ -635,29 +601,23 @@ (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas :all}}} (mt/with-premium-features #{} (mt/user-http-request :rasta :put 403 endpoint {:name "Table Test 2"})))) - (testing "a non-admin cannot update table metadata if they have no data model permissions for the DB" (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas :none}}} (mt/user-http-request :rasta :put 403 endpoint {:name "Table Test 2"}))) - (testing "a non-admin cannot update table metadata if they only have data model permissions for other schemas" (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {"PUBLIC" :none "different schema" :all}}}} (mt/user-http-request :rasta :put 403 endpoint {:name "Table Test 2"}))) - (testing "a non-admin cannot update table metadata if they only have data model permissions for other tables" (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {"PUBLIC" {table-id :none table-id-2 :all}}}}} (mt/user-http-request :rasta :put 403 endpoint {:name "Table Test 2"}))) - (testing "a non-admin can update table metadata if they have data model perms for the DB" (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas :all}}} (mt/user-http-request :rasta :put 200 endpoint {:name "Table Test 2"}))) - (testing "a non-admin can update table metadata if they have data model perms for the schema" (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {"PUBLIC" :all}}}} (mt/user-http-request :rasta :put 200 endpoint {:name "Table Test 3"}))) - (testing "a non-admin can update table metadata if they have data model perms for the table" (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {"PUBLIC" {table-id :all}}}}} (mt/user-http-request :rasta :put 200 endpoint {:name "Table Test 3"}))))))) @@ -670,10 +630,8 @@ (testing "A non-admin can trigger a rescan of field values if they have data model perms for the table" (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {"PUBLIC" {table-id :none}}}}} (mt/user-http-request :rasta :post 403 (format "table/%d/rescan_values" table-id))) - (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {"PUBLIC" {table-id :all}}}}} (mt/user-http-request :rasta :post 200 (format "table/%d/rescan_values" table-id)))) - (testing "A non-admin with no data access can trigger a re-scan of field values if they have data model perms" (t2/update! :model/FieldValues :field_id (mt/id :venues :price) {:values [10 20 30 40]}) (is (= [10 20 30 40] (t2/select-one-fn :values :model/FieldValues, :field_id (mt/id :venues :price)))) @@ -690,7 +648,6 @@ (testing "A non-admin can discard field values if they have data model perms for the table" (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {"PUBLIC" {table-id :none}}}}} (mt/user-http-request :rasta :post 403 (format "table/%d/discard_values" table-id))) - (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {"PUBLIC" {table-id :all}}}}} (mt/user-http-request :rasta :post 200 (format "table/%d/discard_values" table-id))))))) @@ -703,7 +660,6 @@ (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {"PUBLIC" {table-id :none}}}}} (mt/user-http-request :rasta :put 403 (format "table/%d/fields/order" table-id) [field-2-id field-1-id])) - (mt/with-all-users-data-perms-graph! {(mt/id) {:data-model {:schemas {"PUBLIC" {table-id :all}}}}} (is (= {:success true} (mt/user-http-request :rasta :put 200 (format "table/%d/fields/order" table-id) @@ -725,21 +681,18 @@ (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :blocked :create-queries :no}} (mt/user-http-request :rasta :get 403 (format "table/%d?include_editable_data_model=true" table-id)))) - (testing "A non-admin without self-service perms for a table can fetch the table if they have data model perms for the DB" (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted :create-queries :no :data-model {:schemas :all}}} (mt/user-http-request :rasta :get 200 (format "table/%d?include_editable_data_model=true" table-id)))) - (testing "A non-admin without self-service perms for a table can fetch the table if they have data model perms for the schema" (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted :create-queries :no :data-model {:schemas {"PUBLIC" :all}}}} (mt/user-http-request :rasta :get 200 (format "table/%d?include_editable_data_model=true" table-id)))) - (testing "A non-admin without self-service perms for a table can fetch the table if they have data model perms for the table" (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted @@ -757,7 +710,6 @@ :data-model {:schemas :none}}} (mt/user-http-request :rasta :get 403 (format "table/%d/query_metadata?include_editable_data_model=true" table-id)))) - (testing "A non-admin with only data model perms for a table can fetch the query metadata when include_editable_data_model=true" (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/field_values_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/field_values_test.clj index 4a366a7c75fd..bd16e2539517 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/field_values_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/field_values_test.clj @@ -27,11 +27,9 @@ (is (= #{hash-key-1} (into #{} (map :hash_key (t2/select :model/FieldValues :field_id (u/the-id field) :type :advanced))))) - (testing "calling a second time shouldn't create new FieldValues" (params.field-values/get-or-create-field-values! field) (is (= 1 (t2/count :model/FieldValues :field_id (u/the-id field) :type :advanced)))) - (testing "changing the impersonation role creates new FieldValues" (impersonation.util-test/with-impersonations! {:impersonations [{:db-id (mt/id) :attribute "impersonation_attr"}] :attributes {"impersonation_attr" "impersonation_role_2"}} diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/application_permissions_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/application_permissions_test.clj index aa5e726d876d..397d1ae677de 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/application_permissions_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/application_permissions_test.clj @@ -26,7 +26,6 @@ :setting :no :subscription :yes}} (:groups graph))))) - (testing "group has no permissions will not be included in the graph" (is (not (contains? (-> (:groups (g-perms/graph)) keys set) group-id)))))) @@ -52,7 +51,6 @@ (is (partial= (:groups new-graph) (:groups updated-graph))) (is (= (inc (:revision current-graph)) (:revision updated-graph))) (is (< initial-revision (a-perm-revision/latest-id))))))) - (testing "Revoke successfully and increase revision" (with-new-group-and-current-graph group-id current-graph (let [new-graph (assoc-in current-graph [:groups group-id :subscription] :no) @@ -60,14 +58,12 @@ updated-graph (g-perms/graph)] (is (= (dissoc (:groups new-graph) group-id) (:groups updated-graph))) (is (= (inc (:revision current-graph)) (:revision updated-graph)))))) - (testing "We can do a no-op and revision won't changes" (with-new-group-and-current-graph _group-id current-graph (g-perms/update-graph! current-graph) (let [updated-graph (g-perms/graph)] (is (= (:groups updated-graph) (:groups updated-graph))) (is (= (:revision current-graph) (:revision updated-graph)))))) - (testing "Failed when try to update permission for admin group" (with-new-group-and-current-graph _group-id current-graph (let [new-graph (assoc-in current-graph [:groups (:id (perms-group/admin)) :subscription] :no)] @@ -75,7 +71,6 @@ clojure.lang.ExceptionInfo #"You cannot create or revoke permissions for the 'Admin' group." (g-perms/update-graph! new-graph)))))) - (testing "Failed when revision is mismatched" (with-new-group-and-current-graph _group-id current-graph (let [new-graph (assoc current-graph :revision (inc (:revision current-graph)))] @@ -83,7 +78,6 @@ clojure.lang.ExceptionInfo #"Looks like someone else edited the permissions and your data is out of date. Please fetch new data and try again." (g-perms/update-graph! new-graph)))))) - (testing "Able to grant for a group that was not in the old graph" (with-new-group-and-current-graph group-id _current-graph ;; subscription is granted for new group by default, so revoke it diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/block_permissions_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/block_permissions_test.clj index 0bf2ebd4a46a..a7ef19b60810 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/block_permissions_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/block_permissions_test.clj @@ -236,7 +236,6 @@ #"You do not have permissions to run this query" (mt/with-current-user user-id (#'qp.perms/check-block-permissions query))))) - (testing "unrestricted overrides block perms for a table even if other tables have legacy-no-self-service" (data-perms/set-table-permission! group-id (mt/id :venues) :perms/view-data :unrestricted) (data-perms/set-table-permission! group-id (mt/id :orders) :perms/view-data :legacy-no-self-service) @@ -322,7 +321,6 @@ clojure.lang.ExceptionInfo #"You do not have permissions to run this query" (mt/rows (process-query-for-card child-card))))) - (testing "Should not be able to run the child Card due to Block permissions" (mt/with-test-user :rasta (is (mi/can-read? parent-card)) @@ -333,7 +331,6 @@ #"You do not have permissions to run this query" (mt/rows (process-query-for-card child-card))) "Even if the user has can-write? on a Card, they should not be able to run it because they are blocked on Card's db")) - (testing "view-data = unrestricted is required to allow running the query" (data-perms/set-table-permission! (perms-group/all-users) (mt/id :venues) :perms/view-data :unrestricted) (is (= [[1] [2]] (mt/rows (process-query-for-card child-card))) @@ -628,20 +625,17 @@ (is (= expected (mt/rows (qp/process-query (:dataset_query card-1))))))) - (testing "Should not be able to run Card 2 directly [Card 2 -> Card 1 -> Source Query]" (binding [qp.perms/*card-id* (u/the-id card-2)] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have permissions to run this query" (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 not be able to run ad-hoc query with Card 2 as source query [Ad-hoc -> Card -> Card -> Source Query]" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -683,21 +677,18 @@ clojure.lang.ExceptionInfo #"You do not have permissions to run this query" (qp/process-query (:dataset_query card-1)))))) - (testing "Should not be able to run Card 2 directly [Card 2 -> Card 1 -> Source Query]" (binding [qp.perms/*card-id* (u/the-id card-2)] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have permissions to run this query" (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 -> Source Query]" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have permissions to run this query" (qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-1-id)}))))) - (testing "Should not be able to run ad-hoc query with Card 2 as source query [Ad-hoc -> Card -> Card -> Source Query]" (is (thrown-with-msg? clojure.lang.ExceptionInfo diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/group_manager_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/group_manager_test.clj index 65b47d6000ba..1c54e19d4d34 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/group_manager_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/models/permissions/group_manager_test.clj @@ -23,7 +23,6 @@ :is_group_manager false} {:id (:id group-2) :is_group_manager false}]) - (is (= #{{:id (:id (perms-group/all-users)) :is_group_manager false} {:id (:id group-1) @@ -31,7 +30,6 @@ {:id (:id group-2) :is_group_manager false}} (user-group-memberships user)))))) - (testing "should be able to remove User from an existing groups" (mt/with-user-in-groups [group-1 {:name "Group 1"} @@ -47,7 +45,6 @@ {:id (:id group-1) :is_group_manager false}} (user-group-memberships user)))))) - (testing "should be able to add and remove users' groups at the same time" (mt/with-user-in-groups [group-1 {:name "Group 1"} @@ -63,7 +60,6 @@ {:id (:id group-2) :is_group_manager false}} (user-group-memberships user)))))) - (testing "Should be able to promote an existing user to Group manager" (mt/with-user-in-groups [group {:name "Group"} @@ -78,7 +74,6 @@ {:id (:id group) :is_group_manager true}} (user-group-memberships user)))))) - (testing "No-op should be fine" (mt/with-user-in-groups [group {:name "Group"} diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/query_processor/middleware/permissions_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/query_processor/middleware/permissions_test.clj index 1098d5cf5771..77081a304b10 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/query_processor/middleware/permissions_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/query_processor/middleware/permissions_test.clj @@ -90,14 +90,11 @@ (testing "A limit is added to MBQL queries if the user has limited download permissions for the DB" (is (= limited-download-max-rows (download-limit (mbql-download-query))))) - (testing "If the query already has a limit lower than the download limit, the limit is not changed" (is (= (dec limited-download-max-rows) (download-limit (lib/limit (mbql-download-query) (dec limited-download-max-rows)))))) - (testing "Native queries are unmodified" (is (= (native-download-query) (ee.qp.perms/apply-download-limit (native-download-query)))))) - (with-download-perms! (mt/id) {:schemas {"PUBLIC" {(mt/id :venues) :limited (mt/id :checkins) :full}}} (mt/with-current-user (mt/user->id :rasta) @@ -105,7 +102,6 @@ the query references" (is (= limited-download-max-rows (download-limit (mbql-download-query))))) - (testing "If the query does not reference the table, a limit is not added" (is (nil? (download-limit (mbql-download-query 'checkins)))))))))) @@ -122,7 +118,6 @@ (testing "The number of rows in a native query result is limited if the user has limited download permissions" (is (= limited-download-max-rows (-> (native-download-query) limit-download-result-rows mt/rows count)))))) - (with-download-perms-for-db! (mt/id) :full (mt/with-current-user (mt/user->id :rasta) (testing "The number of rows in a native query result is not limited if the user has full download permissions" @@ -223,7 +218,6 @@ :limit 10}} :endpoints [:card :dataset] :assertions {:csv (fn [results] (is (= 3 (csv-row-count results))))}}) - (streaming-test/do-test! "An admin has full download permissions, even if downloads for All Users are limited" {:query {:database (mt/id) @@ -233,7 +227,6 @@ :user :crowberto :endpoints [:card :dataset] :assertions {:csv (fn [results] (is (= 10 (csv-row-count results))))}})) - (with-download-perms! (mt/id) {:schemas {"PUBLIC" :limited}} (streaming-test/do-test! "A user with limited download perms for a schema has their query results limited for queries on that schema" @@ -243,7 +236,6 @@ :limit 10}} :endpoints [:card :dataset] :assertions {:csv (fn [results] (is (= 3 (csv-row-count results))))}})) - (with-download-perms! (mt/id) {:schemas {"PUBLIC" {(mt/id :users) :full (mt/id :categories) :full (mt/id :venues) :limited @@ -260,7 +252,6 @@ :limit 10}} :endpoints [:card :dataset] :assertions {:csv (fn [results] (is (= 3 (csv-row-count results))))}}) - (streaming-test/do-test! "A user with limited download perms for a table still has full download perms for MBQL queries on other tables" {:query {:database (mt/id) @@ -269,7 +260,6 @@ :limit 10}} :endpoints [:card :dataset] :assertions {:csv (fn [results] (is (= 10 (csv-row-count results))))}}) - (streaming-test/do-test! "A user with limited download perms for a table has limited download perms for native queries on all tables" {:query (mt/native-query {:query "SELECT * FROM checkins LIMIT 10;"}) @@ -292,7 +282,6 @@ (is (partial= {:error "You do not have permissions to download the results of this query."} results)))}}) - (streaming-test/do-test! "An admin can always run download queries, even if the All Users group has no download permissions " {:query {:database (mt/id) @@ -302,7 +291,6 @@ :user :crowberto :endpoints [:card :dataset] :assertions {:csv (fn [results] (is (= 10 (csv-row-count results))))}})) - (with-download-perms! (mt/id) {:schemas {"PUBLIC" :none}} (streaming-test/do-test! "A user with no download perms for a schema receives an error response for download queries on that schema" @@ -316,7 +304,6 @@ (is (partial= {:error "You do not have permissions to download the results of this query."} results)))}})) - (with-download-perms! (mt/id) {:schemas {"PUBLIC" {(mt/id :venues) :none (mt/id :checkins) :full (mt/id :users) :full @@ -333,7 +320,6 @@ (is (partial= {:error "You do not have permissions to download the results of this query."} results)))}}) - (streaming-test/do-test! "A user with no download perms for a table still has full download perms for MBQL queries on other tables" {:query {:database (mt/id) @@ -342,7 +328,6 @@ :limit 10}} :endpoints [:card :dataset] :assertions {:csv (fn [results] (is (= 10 (csv-row-count results))))}}) - (streaming-test/do-test! "A user with no download perms for a table has no download perms for native queries on all tables" {:query (mt/native-query {:query "SELECT * FROM checkins LIMIT 10;"}) @@ -372,7 +357,6 @@ (is (partial= {:error "You do not have permissions to download the results of this query."} results)))}}) - (streaming-test/do-test! "A user has limited downloads for a query with a join if they have limited permissions for one of the tables" {:query (mt/mbql-query checkins @@ -453,7 +437,6 @@ :limit 10}} :endpoints [:card :dataset] :assertions {:csv (fn [results] (is (= 10 (csv-row-count results))))}})) - (with-download-perms! (mt/id) {:schemas {"PUBLIC" {(mt/id :categories) :none (mt/id :checkins) :limited}}} (streaming-test/do-test! @@ -464,7 +447,6 @@ :limit 10}} :endpoints [:card :dataset] :assertions {:csv (fn [results] (is (= 3 (csv-row-count results))))}})) - (with-download-perms! (mt/id) {:schemas {"PUBLIC" {(mt/id :categories) :none (mt/id :checkins) :none}}} (streaming-test/do-test! diff --git a/enterprise/backend/test/metabase_enterprise/api/session_test.clj b/enterprise/backend/test/metabase_enterprise/api/session_test.clj index 127f6deef39a..399404f7687d 100644 --- a/enterprise/backend/test/metabase_enterprise/api/session_test.clj +++ b/enterprise/backend/test/metabase_enterprise/api/session_test.clj @@ -141,13 +141,11 @@ {:id session-id :key_hashed key-hashed :user_id user-id :created_at :%now :last_active_at :%now}) (is (some? (#'mw.session/current-user-info-for-session session-key nil)))) - (testing "Session with last_active_at older than timeout should be expired" (t2/query-one {:update (t2/table-name :model/Session) :set {:last_active_at (h2x/add-interval-honeysql-form (mdb/db-type) :%now -301 :second)} :where [:= :key_hashed key-hashed]}) (is (nil? (#'mw.session/current-user-info-for-session session-key nil)))) - (testing "Session with last_active_at just within timeout should be valid" (t2/query-one {:update (t2/table-name :model/Session) :set {:last_active_at (h2x/add-interval-honeysql-form (mdb/db-type) :%now -299 :second)} @@ -162,12 +160,10 @@ (let [session-id (session/generate-session-id) session-key (str (random-uuid)) key-hashed (session/hash-session-key session-key)] - (testing "newly created session (NULL last_active_at) should be valid" (t2/insert! (t2/table-name :model/Session) {:id session-id :key_hashed key-hashed :user_id user-id :created_at :%now}) (is (some? (#'mw.session/current-user-info-for-session session-key nil)))) - (testing "old session with NULL last_active_at should be expired" (t2/query-one {:update (t2/table-name :model/Session) :set {:created_at (h2x/add-interval-honeysql-form (mdb/db-type) :%now -301 :second)} @@ -186,11 +182,9 @@ (session/clear-session-activity-cache!) (t2/insert! (t2/table-name :model/Session) {:id session-id :key_hashed key-hashed :user_id user-id :created_at :%now}) - (testing "first call should update last_active_at" (#'mw.session/maybe-update-session-activity! session-key) (is (some? (t2/select-one-fn :last_active_at (t2/table-name :model/Session) :key_hashed key-hashed)))) - (testing "immediate second call should be throttled (no error, just skipped)" (let [first-value (t2/select-one-fn :last_active_at (t2/table-name :model/Session) :key_hashed key-hashed)] (#'mw.session/maybe-update-session-activity! session-key) diff --git a/enterprise/backend/test/metabase_enterprise/audit_app/analytics_dev_test.clj b/enterprise/backend/test/metabase_enterprise/audit_app/analytics_dev_test.clj index b62a83745f8c..94472dd744ec 100644 --- a/enterprise/backend/test/metabase_enterprise/audit_app/analytics_dev_test.clj +++ b/enterprise/backend/test/metabase_enterprise/audit_app/analytics_dev_test.clj @@ -44,7 +44,6 @@ (is (= "internal@metabase.com" (get-in result [:nested :creator_id]))) (is (nil? (:metabase_version result)) "metabase_version should be removed") (is (nil? (:is_writable result)) "is_writable should be removed"))) - (testing "yaml->canonical for database YAML sets is_audit true and strips fields" (let [yaml-data {:name "Internal Metabase Database" :creator_id "user@example.com" @@ -81,7 +80,6 @@ (is (= ee-audit/default-db-name (:name db))) (is (= :postgres (:engine db))) (is (= user-id (:creator_id db))))) - (testing "create-analytics-dev-database! returns existing database if already created" (let [user-id (mt/user->id :crowberto) db1 (analytics-dev/create-analytics-dev-database! user-id) @@ -98,7 +96,6 @@ (is (some? found)) (is (false? (:is_audit found))) (is (= ee-audit/default-db-name (:name found)))))) - (testing "find-analytics-dev-database does not find audit databases" (mt/with-temp [:model/Database _ {:name ee-audit/default-db-name :engine "postgres" @@ -215,20 +212,17 @@ synced-fields (when table (get-synced-field-names (:id table))) actual-fields (when table (get-actual-field-names analytics-db table))] - (when table (testing "Expected vs Actual" (let [missing-from-actual (set/difference expected-fields actual-fields) extra-in-actual (set/difference actual-fields expected-fields)] (is (empty? missing-from-actual)) (is (empty? extra-in-actual)))) - (testing "Synced vs Actual" (let [missing-from-sync (set/difference actual-fields synced-fields) extra-in-sync (set/difference synced-fields actual-fields)] (is (empty? missing-from-sync)) (is (empty? extra-in-sync)))) - (testing "Expected vs Synced" (let [missing-from-sync (set/difference expected-fields synced-fields) extra-in-sync (set/difference synced-fields expected-fields)] diff --git a/enterprise/backend/test/metabase_enterprise/audit_app/api/user_test.clj b/enterprise/backend/test/metabase_enterprise/audit_app/api/user_test.clj index a42398bedcbb..0bbb43362336 100644 --- a/enterprise/backend/test/metabase_enterprise/audit_app/api/user_test.clj +++ b/enterprise/backend/test/metabase_enterprise/audit_app/api/user_test.clj @@ -20,7 +20,6 @@ (mt/assert-has-premium-feature-error "Audit app" (mt/user-http-request user-id :delete 402 (format "ee/audit-app/user/%d/subscriptions" user-id)))))) - (mt/with-premium-features #{:audit-app} (doseq [run-type [:admin :non-admin]] (mt/with-temp [:model/User {user-id :id} {} diff --git a/enterprise/backend/test/metabase_enterprise/audit_app/audit_test.clj b/enterprise/backend/test/metabase_enterprise/audit_app/audit_test.clj index 6a67228c2351..ad069a87a91c 100644 --- a/enterprise/backend/test/metabase_enterprise/audit_app/audit_test.clj +++ b/enterprise/backend/test/metabase_enterprise/audit_app/audit_test.clj @@ -57,7 +57,6 @@ "No cards created for Audit DB.")) (t2/delete! :model/Database :is_audit true) (audit/last-analytics-checksum! 0)) - (testing "Audit DB content is installed when it is found" (is (= ::ee-audit/installed (ee-audit/ensure-audit-db-installed!))) (is (= audit/audit-db-id (t2/select-one-fn :id :model/Database {:where [:= :is_audit true]})) @@ -65,7 +64,6 @@ (is (some? (io/resource "instance_analytics"))) (is (not= 0 (t2/count :model/Card {:where [:= :database_id audit/audit-db-id]})) "Cards should be created for Audit DB when the content is there.")) - (testing "Cards in the audit collection have non-empty :result_metadata after installation" (let [audit-cards (t2/select [:model/Card :id :name :result_metadata :card_schema] :database_id audit/audit-db-id) @@ -74,7 +72,6 @@ (is (empty? audit-cards-no-metadata) (str "Cards without :result_metadata: " (pr-str (mapv :name audit-cards-no-metadata)))))) - (testing "Audit DB starts with no permissions for all users" (is (= {:perms/manage-database :no :perms/download-results :one-million-rows @@ -84,7 +81,6 @@ :perms/transforms :no} (-> (data-perms.graph/data-permissions-graph :db-id audit/audit-db-id :audit? true) (get-in [(u/the-id (perms-group/all-users)) audit/audit-db-id]))))) - (testing "Audit DB does not have scheduled syncs" (let [db-has-sync-job-trigger? (fn [db-id] (contains? @@ -92,7 +88,6 @@ (task/job-info "metabase.task.sync-and-analyze.job"))) db-id))] (is (not (db-has-sync-job-trigger? audit/audit-db-id))))) - (testing "Audit DB doesn't get re-installed unless the engine changes" (with-redefs [ee.audit.settings/load-analytics-content (constantly nil)] (is (= ::ee-audit/no-op (ee-audit/ensure-audit-db-installed!))) @@ -137,7 +132,6 @@ (is (= '("metabase.task.update-field-values.trigger.13371337") (get-audit-db-trigger-keys)) "no sync scheduled after installation") - (with-redefs [task.sync-databases/job-context->database-id (constantly audit/audit-db-id)] (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -297,38 +291,30 @@ ;; Create another field that doesn't have a lowercase version :model/Field {single-field-id :id} {:table_id single-table-id :name "PRODUCT"}] - ;; Call the function we're testing (#'ee-audit/adjust-audit-db-to-source! {:id audit-db-id}) - (testing "Database engine should be set to postgres" (is (= :postgres (t2/select-one-fn :engine :model/Database :id audit-db-id)))) - (testing "Tables with existing lowercase versions should not be modified" (is (= "USERS" (t2/select-one-fn :name :model/Table :id upper-table-id))) (is (= "users" (t2/select-one-fn :name :model/Table :id lower-table-id)))) - (testing "Tables without lowercase versions should be converted to lowercase" (is (= "orders" (t2/select-one-fn :name :model/Table :id single-table-id)))) - (testing "Tables with nil schemas should not be changed if a table with a schema exists" (is (= 2 (t2/count :model/Table {:where [:= :name "accounts"]})))) - (testing "Tables with nil schemas have their schema set to \"public\"" (is (= "public" (t2/select-one-fn :schema :model/Table :id no-schema-table)))) - (testing "Fields with existing lowercase versions should not be modified" (is (= "EMAIL" (t2/select-one-fn :name :model/Field :id upper-field-id))) (is (= "email" (t2/select-one-fn :name :model/Field :id lower-field-id)))) - (testing "Fields without lowercase versions should be converted to lowercase" (is (= "product" (t2/select-one-fn :name :model/Field :id single-field-id))))))) diff --git a/enterprise/backend/test/metabase_enterprise/audit_app/pages/common_test.clj b/enterprise/backend/test/metabase_enterprise/audit_app/pages/common_test.clj index 131a49a17aaa..f01c9cfeb0c8 100644 --- a/enterprise/backend/test/metabase_enterprise/audit_app/pages/common_test.clj +++ b/enterprise/backend/test/metabase_enterprise/audit_app/pages/common_test.clj @@ -81,13 +81,11 @@ (#'common/CTEs->subselects {:with [[:most_popular {:from [[:view_log :vl]]}]] :from [[:most_popular :mp]]})))) - (testing "JOIN substitution" (is (= {:left-join [[{:from [[:query_execution :qe]]} :qe_count] [:= :qe_count.executor_id :u.id]]} (#'common/CTEs->subselects {:with [[:qe_count {:from [[:query_execution :qe]]}]] :left-join [:qe_count [:= :qe_count.executor_id :u.id]]})))) - (testing "IN subselect substitution" (is (= {:from [[{:from [[:report_dashboardcard :dc]] :where [:in :d.id {:from [[{:from [[:view_log :vl]]} :most_popular]]}]} :dash_avg_running_time]]} @@ -96,7 +94,6 @@ [:dash_avg_running_time {:from [[:report_dashboardcard :dc]] :where [:in :d.id {:from [:most_popular]}]}]] :from [:dash_avg_running_time]})))) - (testing "putting it all together" (is (= {:select [:mp.dashboard_id] :from [[{:select [[:d.id :dashboard_id]] @@ -107,7 +104,6 @@ :left-join [[{:select [:qe.card_id] :from [[:query_execution :qe]]} :rt] [:= :dc.card_id :rt.card_id] - [:report_dashboard :d] [:= :dc.dashboard_id :d.id]] :where [:in :d.id {:select [:dashboard_id] diff --git a/enterprise/backend/test/metabase_enterprise/audit_app/permissions_test.clj b/enterprise/backend/test/metabase_enterprise/audit_app/permissions_test.clj index d822a7d76020..f8128c6aa4c3 100644 --- a/enterprise/backend/test/metabase_enterprise/audit_app/permissions_test.clj +++ b/enterprise/backend/test/metabase_enterprise/audit_app/permissions_test.clj @@ -44,7 +44,6 @@ {:database audit/audit-db-id :type :query :query {:source-table (str "card__" (u/the-id audit-card))}}))))) - (testing "A non-native query can be run on views in the audit DB" (let [audit-view (t2/select-one :model/Table :db_id audit/audit-db-id @@ -70,7 +69,6 @@ {:database audit/audit-db-id :type :native :native {:query "SELECT * FROM v_audit_log;"}})))) - (testing "Non-native queries are not allowed to run on tables in the audit DB that are not views" ;; Nothing should be synced directly from the audit DB, just loaded via serialization, so only the views ;; should have metadata present in the app DB in the first place. But in case this changes, we want to @@ -84,7 +82,6 @@ {:database audit/audit-db-id :type :query :query {:source-table (u/the-id table)}}))))) - (testing "Users without access to the audit collection cannot run any queries on the audit DB, even if they have data perms for the audit DB" (mt/with-full-data-perms-for-all-users! diff --git a/enterprise/backend/test/metabase_enterprise/cache/cache_test.clj b/enterprise/backend/test/metabase_enterprise/cache/cache_test.clj index 21abf8c29259..5843b2f98145 100644 --- a/enterprise/backend/test/metabase_enterprise/cache/cache_test.clj +++ b/enterprise/backend/test/metabase_enterprise/cache/cache_test.clj @@ -121,7 +121,6 @@ (testing "Non-admins have no general access to cache config" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "cache/")))) - (testing "Non-admins can access cache config only if they have write access to the model" (mt/with-all-users-data-perms-graph! {db-id {:details :yes}} (doseq [[model id] [["question" card-id] @@ -142,7 +141,6 @@ (is (nil? (mt/user-http-request :rasta :delete 204 "cache/" {:model model :model_id id}))))))) - (testing "Non-admins cannot access cache config if they do not have write access to the model" (mt/with-all-users-data-perms-graph! {db-id {:details :no}} (mt/with-non-admin-groups-no-collection-perms collection-id @@ -159,7 +157,6 @@ :strategy {:type "nocache" :name "card1"}}) (mt/user-http-request :rasta :post 403 "cache/invalidate" (keyword model) [id]) - (mt/user-http-request :rasta :delete 403 "cache/" {:model model :model_id id})))))))))) @@ -192,11 +189,9 @@ (select-keys [:cached]))) invalidate! (fn [status & args] (apply mt/user-http-request :crowberto :post status "cache/invalidate" args))] - (is (=? {:data [{:model "database" :model_id (mt/id)}]} (mt/user-http-request :crowberto :get 200 "cache/" :model "database"))) - (testing "making a query will cache it" (is (=? {:cached nil :data some?} (run-query! card1-id))) @@ -204,7 +199,6 @@ (run-query! card1-id))) (is (=? {:cached some? :data some?} (run-query! card2-id)))) - (testing "invalidation drops cache only for affected card" (is (=? {:count 1} (invalidate! 200 :question card2-id :include :overrides))) @@ -212,20 +206,17 @@ (run-query! card1-id))) (is (=? {:cached nil :data some?} (run-query! card2-id)))) - (testing "but invalidating a whole config drops cache for any affected card" (doseq [card-id [card1-id card2-id]] (is (=? {:count 1} (invalidate! 200 :database (mt/id)))) (is (=? {:cached nil :data some?} (run-query! card-id {:ignore_cache true}))))) - (testing "when invalidating database config directly, dashboard-related queries are still cached" (is (=? {:count 1} (invalidate! 200 :database (mt/id)))) (is (=? {:cached some? :data some?} (run-query! card1-id {:dashboard_id (:id dash)})))) - (testing "but with overrides - will go through every card and mark cache invalidated" ;; not a concrete number here since (mt/id) can have a bit more than 2 cards we've currently defined (is (=? {:count pos-int?} diff --git a/enterprise/backend/test/metabase_enterprise/cache/strategies_test.clj b/enterprise/backend/test/metabase_enterprise/cache/strategies_test.clj index 2fc8230c02b8..8d05c29e03a2 100644 --- a/enterprise/backend/test/metabase_enterprise/cache/strategies_test.clj +++ b/enterprise/backend/test/metabase_enterprise/cache/strategies_test.clj @@ -43,7 +43,6 @@ (mt/with-clock #t "2024-02-13T10:00:06Z" (is (=? (mkres nil) (-> (qp/process-query query) (dissoc :data))))))))) - (mt/with-model-cleanup [[:model/QueryCache :updated_at]] (testing "strategy = duration" (let [query (assoc query :cache-strategy {:type :duration @@ -62,7 +61,6 @@ (mt/with-clock #t "2024-02-13T10:01:01Z" (is (=? (mkres nil) (-> (qp/process-query query) (dissoc :data))))))))) - (mt/with-model-cleanup [[:model/QueryCache :updated_at]] (testing "strategy = schedule" (let [query (assoc query :cache-strategy {:type :schedule @@ -141,7 +139,6 @@ (mt/with-clock (t 101) ;; avg execution time 1s * multiplier 100 + 1 (is (=? (mkres nil) (-> (qp/process-query q) (dissoc :data)))))))))) - (testing "strategy = duration" (mt/with-model-cleanup [[:model/QueryCache :updated_at]] (mt/with-clock (t 0) @@ -157,7 +154,6 @@ (mt/with-clock (t 61) (is (=? (mkres nil) (-> (qp/process-query q) (dissoc :data)))))))))) - (testing "strategy = schedule" (mt/with-model-cleanup [[:model/QueryCache :updated_at]] (mt/with-clock (t 0) @@ -176,7 +172,6 @@ (let [q (#'qp.card/query-for-card card3 [] {} {} {})] (is (=? (mkres nil) (-> (qp/process-query q) (dissoc :data))))))))) - (testing "default strategy = ttl" (let [q (#'qp.card/query-for-card card4 [] {} {} {})] (is (=? {:type :ttl :multiplier 200} diff --git a/enterprise/backend/test/metabase_enterprise/cache/task/refresh_cache_configs_test.clj b/enterprise/backend/test/metabase_enterprise/cache/task/refresh_cache_configs_test.clj index ccdf1791d76b..898afce12a22 100644 --- a/enterprise/backend/test/metabase_enterprise/cache/task/refresh_cache_configs_test.clj +++ b/enterprise/backend/test/metabase_enterprise/cache/task/refresh_cache_configs_test.clj @@ -123,17 +123,13 @@ ;; Sanity check that the query actually runs (is (= [[1000]] (mt/rows (run-query-for-card-id card-id [])))) (is (= 1 (count (to-rerun)))) - (run-query-for-card-id card-id params-1) (is (= [nil param-val-1] (map param-vals (to-rerun)))) - (run-query-for-card-id card-id params-2) (is (= [nil param-val-1 param-val-2] (map param-vals (to-rerun)))) - (testing "Running a parameterized query again bumps it up in the result list, but base query comes first" (run-query-for-card-id card-id params-2) (is (= [nil param-val-2 param-val-1] (map param-vals (to-rerun))))) - (testing "Only base query + *parameterized-queries-to-rerun-per-card* queries are returned" (binding [task.cache/*parameterized-queries-to-rerun-per-card* 1] (is (= [nil param-val-2] (map param-vals (to-rerun)))))))))))) @@ -149,24 +145,19 @@ (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:card_id card-id})] (is (= [{:query {} :card-id card-id}] (t2/select :model/Query (@#'task.cache/scheduled-base-query-to-rerun-honeysql card-id)))))) - (testing "We don't rerun a query execution older than 30 days" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:started_at (t/minus (t/offset-date-time) (t/days 31))})] (is (= [] (t2/select :model/Query (@#'task.cache/scheduled-base-query-to-rerun-honeysql card-id)))))) - (testing "We don't rerun a cache refresh query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:context :cache-refresh})] (is (= [] (t2/select :model/Query (@#'task.cache/scheduled-base-query-to-rerun-honeysql card-id)))))) - (testing "We don't rerun an errored query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:error "Error"})] (is (= [] (t2/select :model/Query (@#'task.cache/scheduled-base-query-to-rerun-honeysql card-id)))))) - (testing "We don't rerun a sandboxed query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:is_sandboxed true})] (is (= [] (t2/select :model/Query (@#'task.cache/scheduled-base-query-to-rerun-honeysql card-id)))))) - (testing "We don't rerun a parameterized query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:parameterized true})] (is (= [] (t2/select :model/Query (@#'task.cache/scheduled-base-query-to-rerun-honeysql card-id))))))))) @@ -188,7 +179,6 @@ (t2/select :model/Query (@#'task.cache/scheduled-parameterized-queries-to-rerun-honeysql card-id rerun-cutoff)))))) - (testing "We don't rerun a query execution older than the provided cutoff" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:card_id card-id @@ -197,7 +187,6 @@ (is (= [] (t2/select :model/Query (@#'task.cache/scheduled-parameterized-queries-to-rerun-honeysql card-id rerun-cutoff)))))) - (testing "We don't rerun a cache refresh query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:card_id card-id @@ -207,7 +196,6 @@ (is (= [] (t2/select :model/Query (@#'task.cache/scheduled-parameterized-queries-to-rerun-honeysql card-id rerun-cutoff)))))) - (testing "We don't rerun an errored query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:card_id card-id @@ -217,7 +205,6 @@ (is (= [] (t2/select :model/Query (@#'task.cache/scheduled-parameterized-queries-to-rerun-honeysql card-id rerun-cutoff)))))) - (testing "We don't rerun a sandboxed query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:card_id card-id @@ -227,7 +214,6 @@ (is (= [] (t2/select :model/Query (@#'task.cache/scheduled-parameterized-queries-to-rerun-honeysql card-id rerun-cutoff)))))) - (testing "We don't rerun a non-parameterized query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query) {:card_id card-id @@ -265,39 +251,32 @@ param-vals #(-> % :query :parameters first :value)] ;; Starting state: no cache entries exist for the query, so nothing to rerun (is (= [] (to-rerun card-id))) - ;; After running the nonparameterized query once, a cache entry is created but not rerunnable yet (is (= [[1000]] (mt/rows (run-query-for-card-id card-id [])))) (is (=? [] (to-rerun card-id))) - ;; Manually 'expire' the cache entry. Now the query is detected as rerunnable! (expire-most-recent-cache-entry!) (is (=? [{:card-id card-id}] (to-rerun card-id))) - ;; Run a parameterized query. A new cache entry is created but not rerunnable yet. (is (= [[0]] (mt/rows (run-query-for-card-id card-id params-1)))) (is (= [nil] (map param-vals (to-rerun card-id)))) - ;; Manually 'expire' the cache entry for the parameterized query. The cache entry is still not rerunnable, ;; because we only rerun parameterized queries if they've had a *cache hit* within the most recent caching ;; period. (expire-most-recent-cache-entry!) (is (= [nil] (map param-vals (to-rerun card-id)))) - ;; Run the parameterized query twice: once to refresh the cache, then again to generate a cache hit. (is (= [[0]] (mt/rows (run-query-for-card-id card-id params-1)))) (is (= [[0]] (mt/rows (run-query-for-card-id card-id params-1)))) ;; Manually 'expire' the cache entry again. Now the cache entry is rerunnable! (expire-most-recent-cache-entry!) (is (= [nil param-val-1] (map param-vals (to-rerun card-id)))) - ;; Run a different parameterized query thrice, to generate a cache entry and two cache hits (is (= [[0]] (mt/rows (run-query-for-card-id card-id params-2)))) (is (= [[0]] (mt/rows (run-query-for-card-id card-id params-2)))) (is (= [[0]] (mt/rows (run-query-for-card-id card-id params-2)))) (expire-most-recent-cache-entry!) (is (= [nil param-val-2 param-val-1] (map param-vals (to-rerun card-id)))) - (testing "Only base query + *parameterized-queries-to-rerun-per-card* queries are returned" (binding [task.cache/*parameterized-queries-to-rerun-per-card* 1] (is (= [nil param-val-2] (map param-vals (to-rerun card-id)))))))))))) @@ -342,7 +321,6 @@ (->> (t2/select :model/Query (@#'task.cache/duration-queries-to-rerun-honeysql [question-cache-config-1] false)) (map #(update % :cache-hash vec))))) - (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-2) {:card_id card-id-2})] (is (= (->> [query-1-rerun-def query-2-rerun-def] @@ -351,7 +329,6 @@ [question-cache-config-1 question-cache-config-2] false)) (map #(update % :cache-hash vec)) (sort-by :card-id))))))) - (testing "Cache configs on dashboards" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-1) {:card_id card-id-1 :dashboard_id dashboard-id}) @@ -364,28 +341,24 @@ [dashboard-cache-config] false)) (map #(update % :cache-hash vec)) (sort-by :card-id))))))) - (testing "We don't rerun a query execution older than 30 days" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-1) {:card_id card-id-1 :started_at (t/minus (t/offset-date-time) (t/days 32))})] (is (= [] (t2/select :model/Query (@#'task.cache/duration-queries-to-rerun-honeysql [question-cache-config-1] false)))))) - (testing "We don't rerun an errored query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-1) {:card_id card-id-1 :error "Error"})] (is (= [] (t2/select :model/Query (@#'task.cache/duration-queries-to-rerun-honeysql [question-cache-config-1] false)))))) - (testing "We don't rerun a sandboxed query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-1) {:card_id card-id-1 :is_sandboxed true})] (is (= [] (t2/select :model/Query (@#'task.cache/duration-queries-to-rerun-honeysql [question-cache-config-1] false)))))) - (testing "We don't rerun a parameterized query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-1) {:card_id card-id-1 @@ -435,19 +408,16 @@ (->> (t2/select :model/Query (@#'task.cache/duration-queries-to-rerun-honeysql [question-cache-config-1] true)) (map #(update % :cache-hash vec))))) - (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-2) {:card_id card-id-2 :cache_hit true :parameterized true})] (is (= (->> [query-1-rerun-def query-2-rerun-def] (sort-by :card-id)) - (->> (t2/select :model/Query (@#'task.cache/duration-queries-to-rerun-honeysql [question-cache-config-1 question-cache-config-2] true)) (map #(update % :cache-hash vec)) (sort-by :card-id))))))) - (testing "Cache configs on dashboards" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-1) {:card_id card-id-1 @@ -466,7 +436,6 @@ [dashboard-cache-config] true)) (map #(update % :cache-hash vec)) (sort-by :card-id))))))) - (testing "We don't rerun a query execution older than 30 days" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-1) {:card_id card-id-1 @@ -474,7 +443,6 @@ :started_at (t/minus (t/offset-date-time) (t/days 32))})] (is (= [] (t2/select :model/Query (@#'task.cache/duration-queries-to-rerun-honeysql [question-cache-config-1] true)))))) - (testing "We don't rerun an errored query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-1) {:card_id card-id-1 @@ -482,7 +450,6 @@ :error "Error"})] (is (= [] (t2/select :model/Query (@#'task.cache/duration-queries-to-rerun-honeysql [question-cache-config-1] true)))))) - (testing "We don't rerun a sandboxed query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-1) {:card_id card-id-1 @@ -490,7 +457,6 @@ :is_sandboxed true})] (is (= [] (t2/select :model/Query (@#'task.cache/duration-queries-to-rerun-honeysql [question-cache-config-1] true)))))) - (testing "We don't rerun a non parameterized query execution" (mt/with-temp [:model/QueryExecution {} (merge (query-execution-defaults query-1) {:card_id card-id-1 @@ -705,7 +671,6 @@ task.cache/maybe-refresh-duration-caches! (fn [] (swap! call-count inc))] (@#'task.cache/refresh-cache-configs!) (is (= 0 @call-count)) - (mt/with-additional-premium-features #{:cache-preemptive} (t2/update! :model/CacheConfig (:id cc) (assoc cc :next_run_at nil)) (is (true? (premium-features/enable-preemptive-caching?))) diff --git a/enterprise/backend/test/metabase_enterprise/checker/format/serdes_schema_test.clj b/enterprise/backend/test/metabase_enterprise/checker/format/serdes_schema_test.clj index a8ec4eb9033d..9cc7b1d0941c 100644 --- a/enterprise/backend/test/metabase_enterprise/checker/format/serdes_schema_test.clj +++ b/enterprise/backend/test/metabase_enterprise/checker/format/serdes_schema_test.clj @@ -69,7 +69,6 @@ {:name "My Fancy DB" :engine "h2"}) (let [{:keys [db-name->dir]} (serdes-schema/build-database-dir-index (str dir "/databases"))] (is (= "my_fancy_db" (get db-name->dir "My Fancy DB"))))))) - (testing "db-name->dir includes ALL databases, not just some" (with-temp-dir (fn [dir] diff --git a/enterprise/backend/test/metabase_enterprise/cloud_proxy/api_test.clj b/enterprise/backend/test/metabase_enterprise/cloud_proxy/api_test.clj index 5554a57b15e6..7937e9a150c3 100644 --- a/enterprise/backend/test/metabase_enterprise/cloud_proxy/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/cloud_proxy/api_test.clj @@ -69,7 +69,6 @@ (testing (str "operation " op " requires superuser") (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 (str "ee/cloud-proxy/" op))))))))) - (testing "superuser can access superuser operations" (mt/with-premium-features #{:hosting} (with-redefs [hm.client/call mock-hm-call] @@ -99,12 +98,10 @@ (let [resp (mt/user-http-request :crowberto :post 200 "ee/cloud-proxy/mb-plan-trial-up-available")] (is (contains? resp :plan_alias)) (is (not (contains? resp :plan-alias)))) - (let [resp (mt/user-http-request :crowberto :post 200 "ee/cloud-proxy/mb-plan-change-plan-preview")] (is (contains? resp :amount_due_now)) (is (contains? resp :next_payment_date)) (is (not (contains? resp :amount-due-now)))) - (let [resp (mt/user-http-request :crowberto :post 200 "ee/cloud-proxy/get-plan")] (is (contains? resp :per_user_price)) (is (contains? resp :billing_period_months)) diff --git a/enterprise/backend/test/metabase_enterprise/content_translation/dictionary_test.clj b/enterprise/backend/test/metabase_enterprise/content_translation/dictionary_test.clj index 66edc055a0d3..9b56e899a1a5 100644 --- a/enterprise/backend/test/metabase_enterprise/content_translation/dictionary_test.clj +++ b/enterprise/backend/test/metabase_enterprise/content_translation/dictionary_test.clj @@ -82,7 +82,6 @@ (is (#'dictionary/is-msgstr-usable "Hello")) (is (#'dictionary/is-msgstr-usable "Hello, world!")) (is (#'dictionary/is-msgstr-usable "123"))) - (testing "Unusable translations" (is (not (#'dictionary/is-msgstr-usable ""))) (is (not (#'dictionary/is-msgstr-usable " "))) @@ -142,7 +141,6 @@ (let [translations (get-translations)] (is (some #(and (= (:locale %) "es") (= (:msgid %) "Hello") (= (:msgstr %) "Hola")) translations)) (is (some #(and (= (:locale %) "fr") (= (:msgid %) "Goodbye") (= (:msgstr %) "Au revoir")) translations)))))) - (testing "CSV without header row is imported correctly" (mt/with-premium-features #{:content-translation} (let [csv-without-header "de,Thank you,Danke\nit,Good morning,Buongiorno" @@ -152,7 +150,6 @@ (let [translations (get-translations)] (is (some #(and (= (:locale %) "de") (= (:msgid %) "Thank you") (= (:msgstr %) "Danke")) translations)) (is (some #(and (= (:locale %) "it") (= (:msgid %) "Good morning") (= (:msgstr %) "Buongiorno")) translations)))))) - (testing "CSV with headers in different order is imported correctly" (mt/with-premium-features #{:content-translation} (let [csv-different-order "Translation,Language,String\nHola,es,Hello\nAu revoir,fr,Goodbye" @@ -193,7 +190,6 @@ (let [translations (get-translations)] (is (some #(and (= (:locale %) "es") (= (:msgid %) "Hello") (= (:msgstr %) "Hola")) translations)) (is (some #(and (= (:locale %) "fr") (= (:msgid %) "Goodbye") (= (:msgstr %) "Au revoir")) translations)))))) - (testing "CSV with 'locale code' header is imported correctly" (mt/with-premium-features #{:content-translation} (let [csv-with-locale-code "locale code,string,translation\nde,Thank you,Danke\nit,Good morning,Buongiorno" @@ -203,7 +199,6 @@ (let [translations (get-translations)] (is (some #(and (= (:locale %) "de") (= (:msgid %) "Thank you") (= (:msgstr %) "Danke")) translations)) (is (some #(and (= (:locale %) "it") (= (:msgid %) "Good morning") (= (:msgstr %) "Buongiorno")) translations)))))) - (testing "CSV with 'language' header is imported correctly" (mt/with-premium-features #{:content-translation} (let [csv-with-language "language,string,translation\npt_BR,Welcome,Bem-vindo\nzh_CN,Hello,你好" @@ -290,7 +285,6 @@ ["pt-br" "Thank you" "Obrigado"]]] (#'dictionary/import-translations! rows) (is (= 4 (count-translations)) "All translations should be imported") - (let [translations (get-translations)] (is (some #(and (= (:locale %) "es") (= (:msgid %) "Hello") diff --git a/enterprise/backend/test/metabase_enterprise/content_verification/api/collection_test.clj b/enterprise/backend/test/metabase_enterprise/content_verification/api/collection_test.clj index 983802acdc8d..1f94b14165fc 100644 --- a/enterprise/backend/test/metabase_enterprise/content_verification/api/collection_test.clj +++ b/enterprise/backend/test/metabase_enterprise/content_verification/api/collection_test.clj @@ -30,16 +30,13 @@ [:type [:maybe :string]]] resp)) (is (= :official (t2/select-one-fn :authority_level :model/Collection (:id resp)))))) - (testing "but the type has to be valid" (mt/user-http-request :crowberto :post 400 "collection" {:name "foo" :authority_level "invalid-type"}))) - (testing "non-admins get 403" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 "collection" {:name "An official collection" :authority_level "official"})))))) - (testing "fails to add an official collection if doesn't have any premium features" (mt/with-premium-features #{} (mt/assert-has-premium-feature-error "Official Collections" (mt/user-http-request :crowberto :post 402 "collection" {:name "An official collection" @@ -55,21 +52,18 @@ (is (= "official" (:authority_level (mt/user-http-request :crowberto :put 200 (format "collection/%d" id) {:authority_level "official"})))) (is (= :official (t2/select-one-fn :authority_level :model/Collection id))))) - (testing "Non-admins can patch without the :authority_level" (mt/with-temp [:model/Collection collection {:name "whatever" :authority_level "official"}] (is (= "official" (-> (mt/user-http-request :rasta :put 200 (str "collection/" (:id collection)) {:name "foo"}) :authority_level))))) - (testing "non-admins get 403" (mt/with-temp [:model/Collection {id :id} {:authority_level nil}] (is (= "official" (:authority_level (mt/user-http-request :crowberto :put 200 (format "collection/%d" id) {:authority_level "official"})))) (is (= :official (t2/select-one-fn :authority_level :model/Collection id))))))) - (testing "fails to update if doesn't have any premium features" (mt/with-premium-features #{} (mt/with-temp @@ -85,7 +79,6 @@ [:model/Collection {id :id} {:authority_level "official"}] (mt/user-http-request :crowberto :put 200 (format "collection/%d" id) {:authority_level "official" :name "New name"}) (is (= "New name" (t2/select-one-fn :name :model/Collection id))))) - (testing "authority_level is not set and update payload contains the key but does not change" (mt/with-temp [:model/Collection {id :id} {}] @@ -106,7 +99,6 @@ {:moderated_item_id model-id :moderated_item_type :card :status :verified})))) - (testing "can use verified questions/models if has :content-verification feature" (mt/with-premium-features #{:content-verification} (is (pos-int? (:id (mt/user-http-request :crowberto :post 200 "moderation-review" diff --git a/enterprise/backend/test/metabase_enterprise/content_verification/api/moderation_review_test.clj b/enterprise/backend/test/metabase_enterprise/content_verification/api/moderation_review_test.clj index c94fe5dc9406..c2c290c31571 100644 --- a/enterprise/backend/test/metabase_enterprise/content_verification/api/moderation_review_test.clj +++ b/enterprise/backend/test/metabase_enterprise/content_verification/api/moderation_review_test.clj @@ -19,7 +19,6 @@ :status "verified" :moderated_item_id 1 :moderated_item_type "card"})))) - (mt/with-premium-features #{:content-verification} (mt/with-temp [:model/Card {card-id :id} {:name "Test Card"}] (mt/with-model-cleanup [:model/ModerationReview] diff --git a/enterprise/backend/test/metabase_enterprise/dashboard_subscription_filters/parameter_test.clj b/enterprise/backend/test/metabase_enterprise/dashboard_subscription_filters/parameter_test.clj index 3fa64690c94c..5b875e119499 100644 --- a/enterprise/backend/test/metabase_enterprise/dashboard_subscription_filters/parameter_test.clj +++ b/enterprise/backend/test/metabase_enterprise/dashboard_subscription_filters/parameter_test.clj @@ -13,7 +13,6 @@ (notification.dashboard/the-parameters [{:id "1" :v "a"} {:id "2" :v "b"}] [{:id "1" :v "no, since it's trumped by the pulse"} {:id "3" :v "yes"}]))))) - (testing "Get params from dashboard only if :dashboard-subscription-filters feature is disabled" (mt/with-premium-features #{} (is (= [{:id "1" :v "no, since it's trumped by the pulse"} diff --git a/enterprise/backend/test/metabase_enterprise/data_complexity_score/complexity_test.clj b/enterprise/backend/test/metabase_enterprise/data_complexity_score/complexity_test.clj index 3e711fb6e5dd..2735b1b35c9c 100644 --- a/enterprise/backend/test/metabase_enterprise/data_complexity_score/complexity_test.clj +++ b/enterprise/backend/test/metabase_enterprise/data_complexity_score/complexity_test.clj @@ -60,7 +60,6 @@ :synonym-pairs {:measurement 0.0 :score 0} :repeated-measures {:measurement 0.0 :score 0}}}}} (#'complexity/score-catalog [] nil)))) - (testing "entity count contributes +10 per entity (lives under :size)" (let [es [(entity :name "orders") (entity :name "customers") @@ -69,7 +68,6 @@ :components {:size {:score 30 :components {:entity-count {:measurement 3.0 :score 30}}}}} (#'complexity/score-catalog es nil))))) - (testing "name collisions stack linearly: 3 identical names = +200 (lives under :ambiguity)" (let [es [(entity :name "orders") (entity :name "orders") @@ -77,27 +75,23 @@ (is (=? {:components {:size {:components {:entity-count {:measurement 3.0 :score 30}}} :ambiguity {:components {:name-collisions {:measurement 2.0 :score 200}}}}} (#'complexity/score-catalog es nil))))) - (testing "collision detection is case-insensitive and trims whitespace" (let [es [(entity :name "Orders") (entity :name " orders ") (entity :name "ORDERS")]] (is (=? {:components {:ambiguity {:components {:name-collisions {:measurement 2.0 :score 200}}}}} (#'complexity/score-catalog es nil))))) - (testing "field count contributes +1 per field (lives under :size, summed across entities)" (let [es [(entity :name "a" :field-count 10) (entity :name "b" :field-count 25)]] (is (=? {:components {:size {:components {:field-count {:measurement 35.0 :score 35}}}}} (#'complexity/score-catalog es nil))))) - (testing "repeated measures contribute +2 per repeat (lives under :ambiguity)" (let [es [(entity :name "invoices" :measure-names ["revenue" "discount"]) (entity :name "subscriptions" :measure-names ["revenue"]) (entity :name "products" :measure-names ["price"])]] (is (=? {:components {:ambiguity {:components {:repeated-measures {:measurement 1.0 :score 2}}}}} (#'complexity/score-catalog es nil))))) - (testing "nil embedder disables synonym scoring" (let [es [(entity :name "customers") (entity :name "clients")]] (is (=? {:components {:ambiguity {:components {:synonym-pairs {:measurement 0.0 :score 0}}}}} @@ -219,14 +213,12 @@ "clients" [0.9 0.1 0.0]})] (is (=? {:components {:ambiguity {:components {:synonym-pairs {:measurement 1.0 :score 50}}}}} (#'complexity/score-catalog es embedder))))) - (testing "orthogonal embeddings produce no synonym pairs" (let [es [(entity :name "customers") (entity :name "widgets")] embedder (mock-embedder {"customers" [1.0 0.0] "widgets" [0.0 1.0]})] (is (=? {:components {:ambiguity {:components {:synonym-pairs {:measurement 0.0 :score 0}}}}} (#'complexity/score-catalog es embedder))))) - (testing "exact-name duplicates don't double-count as synonym pairs" (let [es [(entity :name "orders") (entity :name "orders") (entity :name "tickets")] embedder (mock-embedder {"orders" [1.0 0.0] @@ -234,7 +226,6 @@ (is (=? {:components {:ambiguity {:components {:name-collisions {:measurement 1.0 :score 100} :synonym-pairs {:measurement 0.0 :score 0}}}}} (#'complexity/score-catalog es embedder))))) - (testing "entities without a vector from the embedder are simply skipped" (let [es [(entity :name "customers") (entity :name "clients") (entity :name "ghost")] ;; "ghost" is missing → not considered. The remaining two are synonyms. @@ -242,7 +233,6 @@ "clients" [0.99 0.01]})] (is (=? {:components {:ambiguity {:components {:synonym-pairs {:measurement 1.0 :score 50}}}}} (#'complexity/score-catalog es embedder))))) - (testing "embedder failure cascades nil through the catalog (no zero-fallback)" (let [es [(entity :name "customers") (entity :name "clients")] embedder (fn [_] (throw (ex-info "boom" {})))] @@ -258,7 +248,6 @@ :components {:entity-count {:measurement 2.0 :score 20} :field-count {:measurement 0.0 :score 0}}}}} (#'complexity/score-catalog es embedder))))) - (testing "throwable with a nil/blank message still records :error as a nonblank string" ;; Regression: we must keep :error present so an embedder failure is distinguishable from a ;; genuine zero-synonym result. Fall back to the exception class name. @@ -538,7 +527,6 @@ "the row with the lowest numeric model_id wins") (is (empty? @unseen) "every expected fetch-batch pair-set must be requested")))))) - (testing "cross-model duplicates: lowest model_id wins, model is secondary tie-break" ;; :kind :question maps to "card" and :kind :table maps to "table" via entity-type->search-model, ;; so the real (model, model_id) query contract is exercised. Both have id 5, so model_ids tie @@ -598,7 +586,6 @@ "non-colliding entity is retained") (is (empty? @unseen) "every expected fetch-batch pair-set must be requested")))))) - (testing "cross-batch, cross-model duplicates: model_id primary, model secondary" ;; Same normalized name from three different batches and three different model types. ;; model_id "5" appears twice (card + dataset); model_id "12" is in a third batch. diff --git a/enterprise/backend/test/metabase_enterprise/data_complexity_score/task/complexity_score_trimmer_test.clj b/enterprise/backend/test/metabase_enterprise/data_complexity_score/task/complexity_score_trimmer_test.clj index f1a4153b4901..444a7504cd5e 100644 --- a/enterprise/backend/test/metabase_enterprise/data_complexity_score/task/complexity_score_trimmer_test.clj +++ b/enterprise/backend/test/metabase_enterprise/data_complexity_score/task/complexity_score_trimmer_test.clj @@ -40,18 +40,14 @@ (insert-score! recent-score-label (t/minus now (t/months 2))) (insert-score! boundary-score-label (t/minus now (t/months 3))) (insert-score! old-score-label (t/minus now (t/months 4))) - (is (= 3 (t2/count :model/DataComplexityScore {:where [:like :fingerprint (str fingerprint-prefix "%")]}))) - (#'complexity-score-trimmer/trim-old-complexity-score-data!) - (is (some? (t2/select-one :model/DataComplexityScore :fingerprint (str fingerprint-prefix recent-score-label)))) (is (some? (t2/select-one :model/DataComplexityScore :fingerprint (str fingerprint-prefix boundary-score-label)))) (is (nil? (t2/select-one :model/DataComplexityScore :fingerprint (str fingerprint-prefix old-score-label)))) - (is (= 2 (t2/count :model/DataComplexityScore {:where [:like :fingerprint (str fingerprint-prefix "%")]})))))) diff --git a/enterprise/backend/test/metabase_enterprise/data_studio/api/query_metadata_test.clj b/enterprise/backend/test/metabase_enterprise/data_studio/api/query_metadata_test.clj index f02b2cd4997c..4ac1ad19b006 100644 --- a/enterprise/backend/test/metabase_enterprise/data_studio/api/query_metadata_test.clj +++ b/enterprise/backend/test/metabase_enterprise/data_studio/api/query_metadata_test.clj @@ -24,7 +24,6 @@ (is (some? response)) (is (= (u/the-id table) (:id response))) (is (= 2 (count (:fields response)))))))) - (testing "Published tables in root collection should be accessible with root collection read permission" (mt/with-temp [:model/Database db {} :model/Table table {:db_id (u/the-id db) :is_published true :collection_id nil} @@ -35,7 +34,6 @@ (let [response (mt/user-http-request :rasta :get 200 (format "table/%d/query_metadata" (u/the-id table)))] (is (some? response)) (is (= (u/the-id table) (:id response))))))) - (testing "Unpublished tables require data permissions" (mt/with-temp [:model/Database db {} :model/Table table {:db_id (u/the-id db) :is_published false} @@ -48,7 +46,6 @@ (data-perms/set-database-permission! group-id db :perms/create-queries :no) (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (format "table/%d/query_metadata" (u/the-id table)))))) - (testing "Data permissions ARE required for unpublished tables" (data-perms/set-database-permission! group-id db :perms/view-data :unrestricted) (data-perms/set-database-permission! group-id db :perms/create-queries :query-builder) diff --git a/enterprise/backend/test/metabase_enterprise/data_studio/permissions/query_test.clj b/enterprise/backend/test/metabase_enterprise/data_studio/permissions/query_test.clj index c747887b4bf6..1d43b559804f 100644 --- a/enterprise/backend/test/metabase_enterprise/data_studio/permissions/query_test.clj +++ b/enterprise/backend/test/metabase_enterprise/data_studio/permissions/query_test.clj @@ -76,7 +76,6 @@ ;; Set table-level permissions (perms/set-table-permission! all-users (mt/id :venues) :perms/create-queries :no) (perms/set-table-permission! all-users (mt/id :venues) :perms/view-data :blocked) - (perms/disable-perms-cache (binding [*current-user-id* user-id *current-user-permissions-set* (delay (perms/user-permissions-set user-id)) diff --git a/enterprise/backend/test/metabase_enterprise/database_replication/api_test.clj b/enterprise/backend/test/metabase_enterprise/database_replication/api_test.clj index 8034707c3593..c140f5125327 100644 --- a/enterprise/backend/test/metabase_enterprise/database_replication/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/database_replication/api_test.clj @@ -152,13 +152,11 @@ :replicated-tables [] :tables-without-pk [] :tables-without-owner-match []}] - (testing "successful case with valid tables and quota" (is (= (merge base-response {:total-estimated-row-count 1000 :replicated-tables [valid-table]}) (api/preview-replication quotas [valid-table])))) - (testing "no-tables error condition" (is (= (merge base-response {:errors {:no-tables true, :no-quota false, :invalid-schema-filters-pattern false} @@ -166,7 +164,6 @@ :tables-without-pk [no-pk-table] :tables-without-owner-match [no-ownership-table]}) (api/preview-replication quotas [no-pk-table no-ownership-table])))) - (testing "no-quota error condition" (let [high-row-table {:table-schema "public", :table-name "high_row_table", :estimated-row-count 500000, :has-pkey true, :has-ownership true}] (is (= (merge base-response @@ -175,13 +172,11 @@ :can-set-replication false :replicated-tables [high-row-table]}) (api/preview-replication quotas [high-row-table]))))) - (testing "invalid-schema-filters-pattern error condition" (is (= (merge base-response {:errors {:no-tables true, :no-quota false, :invalid-schema-filters-pattern true} :can-set-replication false}) (api/preview-replication quotas {:error "Invalid schema pattern"})))) - (testing "mixed tables with various conditions" (is (= (merge base-response {:total-estimated-row-count 1000 @@ -189,7 +184,6 @@ :tables-without-pk [no-pk-table] :tables-without-owner-match [no-ownership-table]}) (api/preview-replication quotas [valid-table no-pk-table no-ownership-table])))) - (testing "no quota available" (let [no-quota-quotas [{:usage 500000, :locked false, :updated-at "2025-08-05T08:48:11Z", :quota-type "rows", :hosting-feature "clickhouse-dwh", :soft-limit 500000}]] (is (= (merge base-response @@ -199,7 +193,6 @@ :can-set-replication false :replicated-tables [valid-table]}) (api/preview-replication no-quota-quotas [valid-table]))))) - (testing "empty tables list" (is (= (merge base-response {:errors {:no-tables true, :no-quota false, :invalid-schema-filters-pattern false} diff --git a/enterprise/backend/test/metabase_enterprise/database_routing/e2e_test.clj b/enterprise/backend/test/metabase_enterprise/database_routing/e2e_test.clj index dac3ce6b83b8..f2ee30d95b88 100644 --- a/enterprise/backend/test/metabase_enterprise/database_routing/e2e_test.clj +++ b/enterprise/backend/test/metabase_enterprise/database_routing/e2e_test.clj @@ -125,7 +125,6 @@ (execute-statement! destination-db "INSERT INTO \"my_database_name\" (str) VALUES ('destination')") (mt/with-temp [:model/DatabaseRouter _ {:database_id (u/the-id router-db) :user_attribute "db_name"}] - (mt/with-test-user :crowberto (is (= [["router"]] (-> (qp/process-query {:database (u/the-id router-db) diff --git a/enterprise/backend/test/metabase_enterprise/database_routing/embedding_test.clj b/enterprise/backend/test/metabase_enterprise/database_routing/embedding_test.clj index b4c878852183..992ed638999c 100644 --- a/enterprise/backend/test/metabase_enterprise/database_routing/embedding_test.clj +++ b/enterprise/backend/test/metabase_enterprise/database_routing/embedding_test.clj @@ -56,7 +56,6 @@ ;; Add test data to both databases (execute-statement! router-db "INSERT INTO \"my_database_name\" (str) VALUES ('router-data')") (execute-statement! destination-db "INSERT INTO \"my_database_name\" (str) VALUES ('destination-data')") - (with-embedding-enabled-and-new-secret-key! (testing "Guest embedding should successfully query the router database" (let [token (card-token card) diff --git a/enterprise/backend/test/metabase_enterprise/dependencies/api_test.clj b/enterprise/backend/test/metabase_enterprise/dependencies/api_test.clj index 7900c8876966..83565b365f75 100644 --- a/enterprise/backend/test/metabase_enterprise/dependencies/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/dependencies/api_test.clj @@ -2706,14 +2706,12 @@ (filter #(= (:db_id %) db-id)) (map :id) set)))) - (testing "both tables returned with unused_only=false" (is (= #{table-1-id table-2-id} (->> (mt/user-http-request :crowberto :get 200 "table" :unused-only false) (filter #(= (:db_id %) db-id)) (map :id) set)))) - (mt/with-temp [:model/Card card {:database_id db-id :table_id table-1-id :dataset_query {:database db-id diff --git a/enterprise/backend/test/metabase_enterprise/dependencies/calculation_test.clj b/enterprise/backend/test/metabase_enterprise/dependencies/calculation_test.clj index bc182d4d8bb0..d696d4316b4e 100644 --- a/enterprise/backend/test/metabase_enterprise/dependencies/calculation_test.clj +++ b/enterprise/backend/test/metabase_enterprise/dependencies/calculation_test.clj @@ -448,7 +448,6 @@ :definition {:filter [:> [:field price-field-id nil] 50]}}] (is (= {:segment #{} :table #{products-id}} (calculation/calculate-deps :segment segment))))) - (testing "segment depending on another segment" (mt/with-temp [:model/Segment {segment-a-id :id :as segment-a} {:table_id products-id :definition {:filter [:> [:field price-field-id nil] 50]}} @@ -528,7 +527,6 @@ (lib/aggregate (lib/sum quantity)))}] (is (= {:measure #{} :segment #{} :table #{orders-id}} (calculation/calculate-deps :measure measure))))) - (testing "measure depending on another measure" (mt/with-temp [:model/Measure {measure-a-id :id :as measure-a} {:name "Measure A" :table_id orders-id diff --git a/enterprise/backend/test/metabase_enterprise/dependencies/metadata_provider_test.clj b/enterprise/backend/test/metabase_enterprise/dependencies/metadata_provider_test.clj index 2455a684c8d3..d811600e2c68 100644 --- a/enterprise/backend/test/metabase_enterprise/dependencies/metadata_provider_test.clj +++ b/enterprise/backend/test/metabase_enterprise/dependencies/metadata_provider_test.clj @@ -114,7 +114,6 @@ ;; OverrideMetadataProvider. (deps.mp/add-override mp :card 2 nil) (deps.mp/add-override mp :transform 123 transform) - (testing "upstream card's columns are correct" (is (=? [{:name "CREATED_AT"} {:name "count"}] diff --git a/enterprise/backend/test/metabase_enterprise/dependencies/metadata_update_test.clj b/enterprise/backend/test/metabase_enterprise/dependencies/metadata_update_test.clj index beeb2c6d2325..c9e467e458b9 100644 --- a/enterprise/backend/test/metabase_enterprise/dependencies/metadata_update_test.clj +++ b/enterprise/backend/test/metabase_enterprise/dependencies/metadata_update_test.clj @@ -296,10 +296,8 @@ (doseq [statement ["CREATE TABLE \"test_table\" (\"id\" INTEGER PRIMARY KEY, \"filter_col\" VARCHAR);" "INSERT INTO \"test_table\" (\"id\", \"filter_col\") VALUES (1, 'value1'), (2, 'value2');"]] (jdbc/execute! one-off-dbs/*conn* [statement])) - ;; Sync the database to pick up the new table (sync/sync-database! (mt/db)) - (let [table-id (t2/select-one-fn :id :model/Table :db_id (mt/id) :name "test_table") filter-field-id (t2/select-one-fn :id :model/Field :table_id table-id :name "filter_col") ;; Step 2: Create a card with a filter on filter_col @@ -315,21 +313,18 @@ :from_entity_id card-id :to_entity_type :table :to_entity_id table-id}) - ;; Step 3: Run analysis - should succeed with no errors (let [card (t2/select-one :model/Card card-id)] ;; NOTE: The metadata provider caches must be small here - if the cache spans a sync it will see ;; old values after the sync! (lib-be/with-metadata-provider-cache (deps.findings/upsert-analysis! card))) - (testing "Initial analysis succeeds" (is (true? (t2/select-one-fn :result :model/AnalysisFinding :analyzed_entity_type :card :analyzed_entity_id card-id))) (is (empty? (deps.analysis-finding-error/errors-for-entity :card card-id)))) - ;; Backdate the analysis timestamp and table/field updated_at so ;; synced-db->direct-dependents-of-changed-tables will detect it as stale (analyzed_at < field.updated_at) ;; after a new sync makes an edit. @@ -343,7 +338,6 @@ {:updated_at old-time}) (t2/update! :model/Table :id table-id {:updated_at old-time})) - ;; Setup complete: Now call the thunk for the actual test run. (thunk {:db-id (:id (mt/db)) :card-id card-id @@ -360,15 +354,12 @@ :analyzed_entity_id card-id)] ;; Step 4: Remove the column used in the filter (jdbc/execute! one-off-dbs/*conn* ["ALTER TABLE \"test_table\" DROP COLUMN \"filter_col\";"]) - ;; Step 5: Re-sync the database. The sync-end event will detect the stale analysis ;; (because analyzed_at < field.updated_at) and mark it for re-analysis. (sync/sync-database! (mt/db)) - ;; Verify the field is now inactive (testing "Field is marked inactive after sync" (is (false? (t2/select-one-fn :active :model/Field :id filter-field-id)))) - ;; Step 6 & 7: The sync-end event marks dependents as stale and triggers the job. ;; Due to race conditions, the entity may be either stale (job hasn't run yet) ;; or already reanalyzed (job ran). Check for either valid outcome. @@ -409,17 +400,14 @@ (u/prog1 (original-deps-check db-id) (swap! db-deps-checked conj [db-id <>])))] (sync/sync-database! (mt/db))) - (testing "sync doesn't update tables or fields that haven't changed" (let [table-after (into {} (t2/select-one :model/Table :id table-id)) filter-field-after (into {} (t2/select-one :model/Field :id filter-field-id))] (is (= table-before table-after)) (is (= filter-field-before filter-field-after)))) - (testing "the DB is were checked for tables that need deps updates, but none were found" (is (=? [[db-id empty?]] @db-deps-checked))) - (testing "No re-analysis was done, the analysis for the card is ~1 hour ago" (let [finding (t2/select-one :model/AnalysisFinding :analyzed_entity_type :card diff --git a/enterprise/backend/test/metabase_enterprise/dependencies/models/analysis_finding_error_test.clj b/enterprise/backend/test/metabase_enterprise/dependencies/models/analysis_finding_error_test.clj index 544e1de53ba8..0806ad567274 100644 --- a/enterprise/backend/test/metabase_enterprise/dependencies/models/analysis_finding_error_test.clj +++ b/enterprise/backend/test/metabase_enterprise/dependencies/models/analysis_finding_error_test.clj @@ -58,7 +58,6 @@ (is (= 1 (t2/count :model/AnalysisFindingError :analyzed_entity_type :card :analyzed_entity_id 1))) - (deps.analysis-finding-error/replace-errors-for-entity! :card 1 []) (is (= 0 (t2/count :model/AnalysisFindingError :analyzed_entity_type :card diff --git a/enterprise/backend/test/metabase_enterprise/dependencies/models/dependency_test.clj b/enterprise/backend/test/metabase_enterprise/dependencies/models/dependency_test.clj index f7456a0009e5..74fba3a3bfb6 100644 --- a/enterprise/backend/test/metabase_enterprise/dependencies/models/dependency_test.clj +++ b/enterprise/backend/test/metabase_enterprise/dependencies/models/dependency_test.clj @@ -56,7 +56,6 @@ (testing "when creating a new card" (is (=? #{(depends-on-> :card (:id card1) :table (mt/id :orders))} (upstream-of :card (:id card1)))) - (testing "that depends on another card" (let [card2 (card/create-card! (wrap-card card1) user)] (deps.test/synchronously-run-backfill!) @@ -65,7 +64,6 @@ (testing "but that doesn't affect the upstream deps of the inner card" (is (=? #{(depends-on-> :card (:id card1) :table (mt/id :orders))} (upstream-of :card (:id card1)))))))) - (testing "when updating an existing card" (testing "to add a new table dep" (card/update-card! {:card-before-update card1 @@ -100,7 +98,6 @@ (is (=? #{(depends-on-> :card id1 :table (mt/id :orders))} (upstream-of :card id1))) (is (=? #{(depends-on-> :card id2 :card id1)} (upstream-of :card id2))) (is (=? #{(depends-on-> :card id3 :card id2)} (upstream-of :card id3)))) - (testing "transitive deps are computed correctly" (testing "for each card" (is (=? {:card #{id2 id3}} @@ -321,7 +318,6 @@ :to_entity_type :table :to_entity_id (:id table2)) "New dependency should exist")) - (testing "swap when new dep already exists - should just delete old" ;; Set up: card2 depends on both table1 and table2 (t2/insert! :model/Dependency [{:from_entity_type :card diff --git a/enterprise/backend/test/metabase_enterprise/dependencies/native_validation_test.clj b/enterprise/backend/test/metabase_enterprise/dependencies/native_validation_test.clj index bed5a3dd351a..f7f9386fd8d3 100644 --- a/enterprise/backend/test/metabase_enterprise/dependencies/native_validation_test.clj +++ b/enterprise/backend/test/metabase_enterprise/dependencies/native_validation_test.clj @@ -137,7 +137,6 @@ (testing "validate-native-query should detect invalid columns in subqueries" (let [mp (deps.tu/default-metadata-provider) driver (:engine (lib.metadata/database mp))] - (testing "Valid query - selecting existing columns from subquery" (validates? mp driver 10 empty?)) (testing "Invalid query - selecting non-existent column from subquery" diff --git a/enterprise/backend/test/metabase_enterprise/dependencies/task/backfill_test.clj b/enterprise/backend/test/metabase_enterprise/dependencies/task/backfill_test.clj index 6607306cda7c..864fe2caacdf 100644 --- a/enterprise/backend/test/metabase_enterprise/dependencies/task/backfill_test.clj +++ b/enterprise/backend/test/metabase_enterprise/dependencies/task/backfill_test.clj @@ -266,7 +266,6 @@ (mt/with-temp [:model/Card {card-id :id} {:dataset_query (mt/mbql-query orders)}] (mark-stale! :card card-id) (is (t2/exists? :model/DependencyStatus :entity_type :card :entity_id card-id :stale true)) - (let [compute-attempts (volatile! 0) failures (inc @#'dependencies.backfill/max-retries)] (with-redefs [env/env (assoc env/env @@ -280,10 +279,8 @@ ;; fail MAX_RETRIES + 1 times (while (< @compute-attempts failures) (backfill-dependencies-single-trigger!)))) - ;; verify card is still stale (not processed) (is (t2/exists? :model/DependencyStatus :entity_type :card :entity_id card-id :stale true)) - ;; verify subsequent runs don't process it (backfill-dependencies-single-trigger!) (is (t2/exists? :model/DependencyStatus :entity_type :card :entity_id card-id :stale true)))))) @@ -306,17 +303,14 @@ ;; Return valid deps on subsequent attempts {:table #{(mt/id :orders)}}))] (is (t2/exists? :model/DependencyStatus :entity_type :card :entity_id card-id :stale true)) - ;; first failure - should be put into retry state (while (zero? @compute-attempts) (backfill-dependencies-single-trigger!)) (is (t2/exists? :model/DependencyStatus :entity_type :card :entity_id card-id :stale true)) - ;; advance time by less than retry delay - should NOT be processed (mt/with-clock (t/plus (t/zoned-date-time) (t/duration 10 :seconds)) (backfill-dependencies-single-trigger!)) (is (t2/exists? :model/DependencyStatus :entity_type :card :entity_id card-id :stale true)) - ;; advance time by more than retry delay - should be processed (mt/with-clock (t/plus (t/zoned-date-time) (t/duration 2 :minutes)) (backfill-dependencies-single-trigger!)) diff --git a/enterprise/backend/test/metabase_enterprise/email/api_test.clj b/enterprise/backend/test/metabase_enterprise/email/api_test.clj index 362decc3c16e..0abde125f7ec 100644 --- a/enterprise/backend/test/metabase_enterprise/email/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/email/api_test.clj @@ -66,7 +66,6 @@ default-email-override-settings original-values) (email-override-settings))))))))))) - (mt/with-premium-features [:cloud-custom-smtp] (testing "Cannot use non-secure settings" (is (= "Invalid email-smtp-security-override value" diff --git a/enterprise/backend/test/metabase_enterprise/embedding_hub/api_test.clj b/enterprise/backend/test/metabase_enterprise/embedding_hub/api_test.clj index 9f066ec59fac..281478378e4c 100644 --- a/enterprise/backend/test/metabase_enterprise/embedding_hub/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/embedding_hub/api_test.clj @@ -33,7 +33,6 @@ (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (true? (get-in response [:checklist :create-models])) "Should detect the user model with nil collection_id"))) - (testing "returns false when all models are in sample collections" ;; Temporarily archive the user model so only sample collection models remain active (mt/with-temp-vals-in-db :model/Card (:id user-model) {:archived true} @@ -47,7 +46,6 @@ (mt/with-temp [:model/Tenant _ {:name "Test Tenant" :slug "test-tenant"}] (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (true? (get-in response [:checklist :create-tenants]))))))) - (testing "create-tenants returns false when tenant is inactive" (mt/with-premium-features #{:embedding} (mt/with-temp [:model/Tenant _ {:name "Inactive Tenant" @@ -55,7 +53,6 @@ :is_active false}] (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (false? (get-in response [:checklist :create-tenants]))))))) - (testing "create-tenants returns false when no tenants exist" (mt/with-premium-features #{:embedding} (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] @@ -69,7 +66,6 @@ :table_id (mt/id :venues)}] (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (true? (get-in response [:checklist :setup-data-segregation-strategy]))))))) - (testing "setup-data-segregation-strategy returns true when connection impersonation is configured" (mt/with-premium-features #{:embedding} (mt/with-temp [:model/PermissionsGroup {group-id :id} {} @@ -78,14 +74,12 @@ :attribute "test-attr"}] (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (true? (get-in response [:checklist :setup-data-segregation-strategy]))))))) - (testing "setup-data-segregation-strategy returns true when database routing is configured" (mt/with-premium-features #{:embedding :database-routing} (mt/with-temp [:model/DatabaseRouter _ {:database_id (mt/id) :user_attribute "test-attr"}] (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (true? (get-in response [:checklist :setup-data-segregation-strategy]))))))) - (testing "setup-data-segregation-strategy returns false when none are configured" (mt/with-premium-features #{:embedding} (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] @@ -99,7 +93,6 @@ :namespace "shared-tenant-collection"}] (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (true? (get-in response [:checklist :enable-tenants])))))))) - (testing "enable-tenants returns false when use-tenants is true but no shared tenant collections exist" (mt/with-premium-features #{:embedding :tenants} (mt/with-temporary-setting-values [use-tenants true] @@ -118,7 +111,6 @@ :table_id (mt/id :venues)}] (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (true? (get-in response [:checklist :data-permissions-and-enable-tenants])))))))) - (testing "returns false when data segregation is not configured even if tenants are created" (mt/with-premium-features #{:embedding :tenants} (mt/with-temporary-setting-values [use-tenants true] @@ -127,7 +119,6 @@ :model/Tenant _ {:name "Test Tenant" :slug "test-tenant"}] (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (false? (get-in response [:checklist :data-permissions-and-enable-tenants])))))))) - (testing "returns false when tenants are not created even if tenants and data segregation are configured" (mt/with-premium-features #{:embedding :sandboxes :tenants} (mt/with-temporary-setting-values [use-tenants true] @@ -144,7 +135,6 @@ (mt/with-premium-features #{:embedding} (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (nil? (:data-isolation-strategy response)))))) - (testing "data-isolation-strategy is row-column-level-security when sandboxes are configured" (mt/with-premium-features #{:embedding :sandboxes} (mt/with-temp [:model/PermissionsGroup {group-id :id} {} @@ -152,7 +142,6 @@ :table_id (mt/id :venues)}] (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (= "row-column-level-security" (:data-isolation-strategy response))))))) - (testing "data-isolation-strategy is connection-impersonation when connection impersonation is configured" (mt/with-premium-features #{:embedding} (mt/with-temp [:model/PermissionsGroup {group-id :id} {} @@ -161,7 +150,6 @@ :attribute "test-attr"}] (let [response (mt/user-http-request :crowberto :get 200 "/ee/embedding-hub/checklist")] (is (= "connection-impersonation" (:data-isolation-strategy response))))))) - (testing "data-isolation-strategy is database-routing when database routing is configured" (mt/with-premium-features #{:embedding :database-routing} (mt/with-temp [:model/DatabaseRouter _ {:database_id (mt/id) diff --git a/enterprise/backend/test/metabase_enterprise/gsheets/api_test.clj b/enterprise/backend/test/metabase_enterprise/gsheets/api_test.clj index 91d95cc0e28e..1c4497276e6b 100644 --- a/enterprise/backend/test/metabase_enterprise/gsheets/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/gsheets/api_test.clj @@ -174,7 +174,6 @@ :hm/response {:status 400 :body {:error-detail "Error detail" :status-reason "Status Reason"}}} - result))) (let [saved (gsheets)] (is (= {} saved)))))))) @@ -305,7 +304,6 @@ :hm/response {:status 400 :body {:error-detail "Error Detail" :type "gdrive"}}} - response)) (is (pos-int? (:db_id response))))))) (testing "when 200 error response" diff --git a/enterprise/backend/test/metabase_enterprise/gsheets/settings_test.clj b/enterprise/backend/test/metabase_enterprise/gsheets/settings_test.clj index 984deb39ad0f..103e17e56f18 100644 --- a/enterprise/backend/test/metabase_enterprise/gsheets/settings_test.clj +++ b/enterprise/backend/test/metabase_enterprise/gsheets/settings_test.clj @@ -34,7 +34,6 @@ (#'gsettings/migrate-gsheet-value {:status "not-connected"}))) (is (= {} @value-updated))) - (testing "old loading values are migrated to current-format" (let [expected-value {:url "https://example.com" :created-at 1234567890 @@ -50,7 +49,6 @@ :created-by-id 1 :db-id 1}))) (is (= expected-value @value-updated)))) - (testing "old complete values are migrated to current-format" (let [expected-value {:url "https://example.com" :created-at 1234567890 diff --git a/enterprise/backend/test/metabase_enterprise/impersonation/api_test.clj b/enterprise/backend/test/metabase_enterprise/impersonation/api_test.clj index 7480e6f07498..20dc8372d40d 100644 --- a/enterprise/backend/test/metabase_enterprise/impersonation/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/impersonation/api_test.clj @@ -21,7 +21,6 @@ (mt/user-http-request :crowberto :put 200 "permissions/graph" graph) (is (=? [impersonation] (t2/select :model/ConnectionImpersonation :group_id (u/the-id group))))) - (testing "A connection impersonation policy can be updated via the permissions graph endpoint" (let [impersonation {:group_id (u/the-id group) :db_id (mt/id) @@ -52,15 +51,12 @@ (filter #(#{impersonation-id-1 impersonation-id-2} (:id %)) (mt/user-http-request :crowberto :get 200 "ee/advanced-permissions/impersonation"))))) - (testing "Test that we can fetch the Connection Impersonation for a specific DB and group" (is (= impersonation-1 (mt/user-http-request :crowberto :get 200 "ee/advanced-permissions/impersonation" :group_id group-id-1 :db_id (mt/id))))) - (testing "Test that a non-admin cannot fetch Connection Impersonation details" (mt/user-http-request :rasta :get 403 "ee/advanced-permissions/impersonation"))) - (testing "Test that the :advanced-permissions flag is required to fetch Connection Impersonation Details" (mt/with-premium-features #{} (mt/user-http-request :crowberto :get 402 "ee/advanced-permissions/impersonation")))))) @@ -75,7 +71,6 @@ :attribute "Attribute Name"}] (mt/user-http-request :crowberto :delete 204 (format "ee/advanced-permissions/impersonation/%d" impersonation-id)) (is (nil? (t2/select-one :model/ConnectionImpersonation :id impersonation-id))))) - (testing "Test that a non-admin cannot delete a Connection Impersonation" (mt/with-temp [:model/PermissionsGroup {group-id :id} {} :model/ConnectionImpersonation {impersonation-id :id :as impersonation} @@ -84,7 +79,6 @@ :attribute "Attribute Name"}] (mt/user-http-request :rasta :delete 403 (format "ee/advanced-permissions/impersonation/%d" impersonation-id)) (is (= impersonation (t2/select-one :model/ConnectionImpersonation :id impersonation-id)))))) - (testing "Test that the :advanced-permissions flag is required to delete a Connection Impersonation" (mt/with-premium-features #{} (mt/with-temp [:model/PermissionsGroup {group-id :id} {} @@ -109,7 +103,6 @@ :unrestricted)] (mt/user-http-request :crowberto :put 200 "permissions/graph" graph)) (is (nil? (t2/select-one :model/ConnectionImpersonation :id impersonation-id))))) - (testing "A connection impersonation policy is not deleted if unrelated permissions are changed" (mt/with-temp [:model/PermissionsGroup {group-id :id} {} :model/ConnectionImpersonation {impersonation-id :id} diff --git a/enterprise/backend/test/metabase_enterprise/impersonation/driver_test.clj b/enterprise/backend/test/metabase_enterprise/impersonation/driver_test.clj index 5c9a3a91cc87..7a345553228b 100644 --- a/enterprise/backend/test/metabase_enterprise/impersonation/driver_test.clj +++ b/enterprise/backend/test/metabase_enterprise/impersonation/driver_test.clj @@ -582,7 +582,6 @@ (testing "both see router DB, but crowberto sees all rows" (impersonation.util-test/with-impersonations! {:impersonations [{:db-id (u/the-id router-database) :attribute "impersonation_attr"}] :attributes {"impersonation_attr" "impersonation.role"}} - (is (= [[1 "hello to user 1 in the router DB"] [2 "hello to user 2 in the router DB"] [1 "hello to user 1 in the router DB"]] @@ -698,10 +697,8 @@ #"Connection impersonation is enabled for this database, but no default role is found" (mt/run-mbql-query venues {:aggregation [[:count]]})))) - ;; Update the test database with a default role that has full permissions (t2/update! :model/Database :id (mt/id) (assoc-in (mt/db) [:details :role] "ACCOUNTADMIN")) - (try ;; User with connection impersonation should not be able to query a table they don't have access to ;; (`LIMITED.ROLE` in CI Snowflake has no data access) @@ -710,7 +707,6 @@ #"Cannot perform SELECT. This session does not have a current database. Call 'USE DATABASE', or use a qualified name." (mt/run-mbql-query venues {:aggregation [[:count]]}))) - ;; Non-impersonated user should still be able to query the table (request/as-admin (is (= [100] @@ -752,7 +748,6 @@ (is (not (str/includes? (-> impersonated-result :data :native_form :query) (:table_name persisted-info))) "Erroneously used the persisted model cache")) - (testing "Query from admin hits the model cache" (is (str/includes? (-> admin-result :data :native_form :query) (:table_name persisted-info)) @@ -793,24 +788,17 @@ (mt/with-premium-features #{:advanced-permissions} (let [venues-table (sql.tx/qualify-and-quote driver/*driver* "test-data" "venues") role-a (u/lower-case-en (mt/random-name))] - (tx/with-temp-roles! driver/*driver* - (impersonation-granting-details driver/*driver* (mt/db)) {role-a {venues-table {}}} - (impersonation-default-user driver/*driver*) (impersonation-default-role driver/*driver*) - (mt/with-temp [:model/Database database {:engine driver/*driver*, :details (impersonation-details driver/*driver* (mt/db))}] (mt/with-db database - (when (driver/database-supports? driver/*driver* :connection-impersonation-requires-role nil) (t2/update! :model/Database :id (mt/id) (assoc-in (mt/db) [:details :role] (impersonation-default-role driver/*driver*)))) - (sync/sync-database! database {:scan :schema}) - (let [tables-set #(->> (driver/describe-database driver/*driver* (t2/select-one :model/Database (mt/id))) diff --git a/enterprise/backend/test/metabase_enterprise/impersonation/util_test.clj b/enterprise/backend/test/metabase_enterprise/impersonation/util_test.clj index 40fbbb892e2c..9713c9af2987 100644 --- a/enterprise/backend/test/metabase_enterprise/impersonation/util_test.clj +++ b/enterprise/backend/test/metabase_enterprise/impersonation/util_test.clj @@ -67,12 +67,10 @@ (with-impersonations! {:impersonations [{:db-id (mt/id) :attribute "KEY"}] :attributes {"KEY" "VAL"}} (is (impersonation.util/impersonated-user?)))) - (testing "Returns true if current user is a superuser, even if they are in a group with an impersonation policy in place" (with-impersonations-for-user! :crowberto {:impersonations [{:db-id (mt/id) :attribute "KEY"}] :attributes {"KEY" "VAL"}} (is (not (impersonation.util/impersonated-user?))))) - (testing "An exception is thrown if no user is bound" (binding [api/*current-user-id* nil] (is (thrown-with-msg? clojure.lang.ExceptionInfo diff --git a/enterprise/backend/test/metabase_enterprise/internal_stats/core_test.clj b/enterprise/backend/test/metabase_enterprise/internal_stats/core_test.clj index 58b78e9000b4..1aa126c60752 100644 --- a/enterprise/backend/test/metabase_enterprise/internal_stats/core_test.clj +++ b/enterprise/backend/test/metabase_enterprise/internal_stats/core_test.clj @@ -36,7 +36,6 @@ (mt/with-temporary-setting-values [enable-embedding-interactive false embedding-app-origins-interactive "example.com"] (is (not (:enabled-embedding-interactive (sut/embedding-settings 0 0)))))) - (testing "with enabled interactive embedding and without a configured app origin" (mt/with-temporary-setting-values [enable-embedding-interactive false] (is (not (:enabled-embedding-interactive (sut/embedding-settings 0 0)))))))) @@ -69,12 +68,10 @@ jwt-shared-secret "asdfasdf" jwt-enabled true] (is (:enabled-embedding-sdk (sut/embedding-settings 0 0))))) - (testing "with sdk disabled and jwt enabled" (mt/with-temporary-setting-values [enable-embedding-sdk false jwt-enabled true] (is (not (:enabled-embedding-sdk (sut/embedding-settings 0 0)))))) - (testing "with sdk enabled and jwt disabled" (mt/with-temporary-setting-values [enable-embedding-sdk true jwt-enabled false] diff --git a/enterprise/backend/test/metabase_enterprise/internal_stats/metabot_test.clj b/enterprise/backend/test/metabase_enterprise/internal_stats/metabot_test.clj index 864b185a69d6..829106d73c6d 100644 --- a/enterprise/backend/test/metabase_enterprise/internal_stats/metabot_test.clj +++ b/enterprise/backend/test/metabase_enterprise/internal_stats/metabot_test.clj @@ -83,91 +83,71 @@ ;; conv-1: yesterday, one model (send-message! conv-1 "What is 2+2?" model 100 50) (backdate-messages! conv-1 yesterday) - ;; conv-2: yesterday, same model different usage (send-message! conv-2 "Tell me a joke" model 200 80) (backdate-messages! conv-2 yesterday) - ;; conv-3: two days ago — out of window (send-message! conv-3 "Old question" model 999 999) (backdate-messages! conv-3 two-days-ago) - ;; conv-4: today — in rolling window (send-message! conv-4 "Today's question" model 888 888) (backdate-messages! conv-4 today) - ;; conv-6: today, same model — exercises rolling aggregation (send-message! conv-6 "Another today question" model 100 100) (backdate-messages! conv-6 today)) - ;; -- BYOK conversation (no metabase/ prefix) -- (mt/with-temporary-setting-values [metabot.settings/llm-metabot-provider "anthropic/claude-sonnet-4-6"] (send-message! conv-5 "BYOK question" model 777 777) (backdate-messages! conv-5 yesterday)) - ;; -- Verify stored data -- (testing "ai-proxy messages have ai_proxied = true on ALL rows (user + assistant)" (let [msgs (t2/select :model/MetabotMessage :conversation_id conv-1)] (is (= 2 (count msgs)) "should have user + assistant messages") (is (every? true? (map :ai_proxied msgs))))) - (testing "ai-proxy usage log has ai_proxied = true" (let [logs (t2/select :model/AiUsageLog :conversation_id conv-1)] (is (= 1 (count logs)) "should have one usage log row per LLM call") (is (every? true? (map :ai_proxied logs))))) - (testing "BYOK messages have ai_proxied = false on ALL rows" (let [msgs (t2/select :model/MetabotMessage :conversation_id conv-5)] (is (= 2 (count msgs))) (is (every? false? (map :ai_proxied msgs))))) - (testing "BYOK usage log has ai_proxied = false" (let [logs (t2/select :model/AiUsageLog :conversation_id conv-5)] (is (= 1 (count logs)) "should have one usage log row per LLM call") (is (every? false? (map :ai_proxied logs))))) - (testing "usage keys are provider/model (metabase/ prefix stripped)" ;; accumulate-usage-xf strips metabase/ prefix → "anthropic/claude-sonnet-4-6" ;; JSON roundtrip keywordizes → :anthropic/claude-sonnet-4-6 (let [msg (t2/select-one :model/MetabotMessage :conversation_id conv-1 :role :assistant)] (is (contains? (:usage msg) (keyword "anthropic" "claude-sonnet-4-6")) "usage key should be provider/model without metabase/ prefix"))) - ;; -- Verify stats aggregation -- (let [stats (sut/metabot-stats)] (testing ":metabot-tokens sums total_tokens for yesterday's ai-proxied messages only" ;; conv-1: 150, conv-2: 280 → total 430 (is (= 430 (:metabot-tokens stats)))) - (testing ":metabot-usage aggregates combined tokens by model" (is (= {"anthropic:claude-sonnet-4-6:tokens" 430} (:metabot-usage stats)))) - (testing ":metabot-queries counts ai-proxied user messages for yesterday" (is (= 2 (:metabot-queries stats)))) - (testing ":metabot-users counts distinct users for yesterday" (is (= 1 (:metabot-users stats)))) - (testing ":metabot-usage-date is yesterday's date" (is (= "2026-03-31" (:metabot-usage-date stats)))) - (testing ":metabot-rolling-usage aggregates today's combined tokens by model" (is (= {"anthropic:claude-sonnet-4-6:tokens" 1976} (:metabot-rolling-usage stats)))) - (testing ":metabot-rolling-usage-date is today's date" (is (= "2026-04-01" (:metabot-rolling-usage-date stats))))) - ;; -- BYOK-only scenario -- (cleanup! conv-1 conv-2 conv-3 conv-4 conv-6) - (testing "returns nil when only BYOK (non-proxied) messages exist yesterday" (is (nil? (sut/metabot-stats)))) - (finally (apply cleanup! all-convs))))))) @@ -387,22 +367,18 @@ "metabase/anthropic/claude-sonnet-4-6"] ;; Yesterday's generation (generate-example-questions! tables model 400 100) - (testing "ai_usage_log row is created with ai_proxied = true" (is (=? [{:ai_proxied true :total_tokens 500}] (t2/select :model/AiUsageLog :id [:> baseline])))) - ;; backdate so it lands in yesterday's window (t2/update! :model/AiUsageLog {:id [:> baseline]} {:created_at yesterday}) - ;; Today's generation — exercises rolling usage (let [before-today (max-usage-log-id)] (generate-example-questions! tables model 200 60) (t2/update! :model/AiUsageLog {:id [:> before-today]} {:created_at today}))) - (testing "metabot-stats includes yesterday totals and today's rolling usage" (is (=? {:metabot-tokens 500 :metabot-usage {"anthropic:claude-sonnet-4-6:tokens" 500} @@ -424,14 +400,11 @@ (mt/with-temporary-setting-values [metabot.settings/llm-metabot-provider "openrouter/anthropic/claude-haiku-4-5"] (generate-example-questions! tables model 400 100) - (testing "ai_usage_log row is created with ai_proxied = false for BYOK" (is (=? [{:ai_proxied false}] (t2/select :model/AiUsageLog :id [:> baseline])))) - (t2/update! :model/AiUsageLog {:id [:> baseline]} {:created_at yesterday})) - (testing "BYOK example question usage does not appear in metabot-stats" (is (nil? (sut/metabot-stats)))) (finally @@ -452,13 +425,11 @@ ;; Chat conversation (send-message! conv-id "What is 2+2?" model 200 50) (backdate-messages! conv-id yesterday) - ;; Example question generation (no conversation) (generate-example-questions! tables model 300 100) (t2/update! :model/AiUsageLog {:id [:> baseline] :source "example_question_generation_batch"} {:created_at yesterday})) - (testing "metabot-stats includes both chat and example question generation" ;; chat: 250, eqg: 400 → total 650 (is (=? {:metabot-tokens 650 diff --git a/enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj b/enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj index 43706aa07771..dc6d85aba496 100644 --- a/enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj +++ b/enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj @@ -42,11 +42,9 @@ :name "Sales Dashboard" :description "Dashboard for sales" :verified true}] - (with-redefs [perms/impersonated-user? (fn [] false) perms/sandboxed-user? (fn [] false) api/*current-user-id* 1] - (testing "search returns postprocessed results for term queries" (with-redefs [search-core/search (fn [_] {:data [order-table]})] (let [args {:term-queries ["orders"] @@ -54,7 +52,6 @@ results (search/search args) expected [(#'search/postprocess-search-result order-table)]] (is (= expected results))))) - (testing "search returns postprocessed results for semantic queries" (with-redefs [search-core/search (fn [_] {:data [dashboard]})] (let [args {:semantic-queries ["sales metrics"] @@ -62,7 +59,6 @@ results (search/search args) expected [(#'search/postprocess-search-result dashboard)]] (is (= expected results))))) - (testing "search combines term and semantic queries using RRF" (with-redefs [search-core/search (fn [context] (if (= (:search-string context) "orders") @@ -76,7 +72,6 @@ (is (= 2 (count results))) (is (some #(= (:id %) 1) results)) (is (some #(= (:id %) 2) results))))) - (testing "search applies RRF to overlapping results" (with-redefs [search-core/search (fn [_] {:data [order-table dashboard]})] @@ -87,14 +82,12 @@ (is (= 2 (count results))) (is (some #(= (:id %) 1) results)) (is (some #(= (:id %) 2) results))))) - (testing "search handles empty results" (with-redefs [search-core/search (fn [_] {:data []})] (let [args {:term-queries ["nonexistent"] :entity-types ["table"]} results (search/search args)] (is (empty? results))))) - (testing "search with metabot verified content flag" (let [metabot {:entity_id "test-bot" :use_verified_content true}] diff --git a/enterprise/backend/test/metabase_enterprise/metabot_analytics/api_test.clj b/enterprise/backend/test/metabase_enterprise/metabot_analytics/api_test.clj index b57cdb2f05d1..bdfe5be8c39e 100644 --- a/enterprise/backend/test/metabase_enterprise/metabot_analytics/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/metabot_analytics/api_test.clj @@ -389,7 +389,6 @@ :profile-id "internal" :total-tokens 8 :data [{:type "text" :text "hi there"}]}) - (let [response (mt/user-http-request :crowberto :get 200 (format "ee/metabot-analytics/conversations/%s" conversation-id))] (is (= conversation-id (:conversation_id response))) @@ -464,7 +463,6 @@ {:type "tool-output" :id "call-failed" :result {:output "SQL query construction failed."}}]}) - (let [response (mt/user-http-request :crowberto :get 200 (format "ee/metabot-analytics/conversations/%s" conversation-id)) queries (:queries response)] diff --git a/enterprise/backend/test/metabase_enterprise/permission_debug/api_test.clj b/enterprise/backend/test/metabase_enterprise/permission_debug/api_test.clj index f312b0b0cd8a..dfe7bc2e9ae4 100644 --- a/enterprise/backend/test/metabase_enterprise/permission_debug/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/permission_debug/api_test.clj @@ -34,7 +34,6 @@ (is (seq (:message response))) (is (= {} (:data response))) (is (= {} (:suggestions response))))) - (testing "should return denied when user lacks read permission" (mt/with-temp [:model/Collection private-collection {} :model/Card private-card {:collection_id (:id private-collection)}] @@ -63,7 +62,6 @@ (is (= "card" (:model-type response))) (is (= '() (:segment response))) (is (= {} (:data response))))) - (testing "should return denied when table access is blocked" (perms/set-table-permission! (perms/all-users-group) (mt/id :checkins) :perms/view-data :blocked) (let [response (mt/user-http-request :crowberto :get 200 "ee/permission_debug" @@ -89,7 +87,6 @@ (is (= "card" (:model-type response))) (is (= '() (:segment response))) (is (seq (:message response))))) - (testing "should return limited when user has limited download permission" (perms/set-table-permission! (perms/all-users-group) (mt/id :checkins) :perms/view-data :unrestricted) (perms/set-table-permission! (perms/all-users-group) (mt/id :checkins) :perms/download-results :ten-thousand-rows) @@ -110,7 +107,6 @@ :user_id "1" :model_id "999" :action_type "unknown/permission")))) - (testing "should require valid parameters" (is (= {:errors {:user_id "integer greater than 0"}, :specific-errors {:user_id ["should be a positive int, received: \"invalid\""]}} @@ -135,16 +131,12 @@ (is (contains? response :message)) (is (contains? response :data)) (is (contains? response :suggestions))) - (testing "decision should be valid enum value" (is (contains? #{"allow" "denied" "limited"} (:decision response)))) - (testing "segment should be a sequence" (is (sequential? (:segment response)))) - (testing "message should be a sequence" (is (sequential? (:message response)))) - (testing "data and suggestions should be maps" (is (map? (:data response))) (is (map? (:suggestions response)))))))) diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/dashboard_api_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/dashboard_api_test.clj index 196fd829be9e..c836b121bc7f 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/dashboard_api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/dashboard_api_test.clj @@ -53,7 +53,6 @@ ;; Verify error response contains dependency information (is (str/includes? (:message response) "content that is not remote synced") "Error message should mention content that is not remote synced")) - ;; Verify the transaction was rolled back - dashboard should not be moved (let [unchanged-dash (t2/select-one :model/Dashboard :id dash-id)] (is (= source-id (:collection_id unchanged-dash)) @@ -78,7 +77,6 @@ ;; This should return 400 with transaction rollback (mt/user-http-request :crowberto :put 400 (str "dashboard/" dash-id) {:collection_id target-id}) - ;; Verify the transaction was completely rolled back (let [unchanged-dash (t2/select-one :model/Dashboard :id dash-id)] (is (= source-id (:collection_id unchanged-dash)) @@ -152,7 +150,6 @@ ;; Verify error response contains dependency information (is (str/includes? (:message response) "content that is not remote synced") "Error message should mention content that is not remote synced")) - ;; Verify no dashboard was created (is (= 1 (t2/count :model/Dashboard :name "Dashboard to Copy")) "No new dashboard should be created after failed copy")))) diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/events_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/events_test.clj index 7ddd470e1ded..333a3bce2682 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/events_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/events_test.clj @@ -514,14 +514,12 @@ :dataset_query (mt/mbql-query venues) :collection_id (:id remote-sync-collection)}] (#'impl/sync-objects! (t/instant) {:by-entity-id {"Card" #{(:entity_id card)}}}) - (let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Card" :model_id (:id card))] (is (= "synced" (:status initial-entry))) (events/publish-event! :event/card-update {:object (assoc card :collection_id (:id normal-collection)) :previous-object card :user-id (mt/user->id :rasta)}) - (let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Card" :model_id (:id card))] (is (= "removed" (:status update-entry))) (is (= (:id initial-entry) (:id update-entry)))))))) @@ -552,13 +550,11 @@ :model/Dashboard dashboard {:name "Test Dashboard" :collection_id (:id remote-sync-collection)}] (#'impl/sync-objects! (t/instant) {:by-entity-id {"Dashboard" #{(:entity_id dashboard)}}}) - (let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Dashboard" :model_id (:id dashboard))] (is (= "synced" (:status initial-entry))) (events/publish-event! :event/dashboard-update {:object (assoc dashboard :collection_id (:id normal-collection)) :user-id (mt/user->id :rasta)}) - (let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Dashboard" :model_id (:id dashboard))] (is (= "removed" (:status update-entry))) (is (= (:id initial-entry) (:id update-entry)))))))) @@ -586,13 +582,11 @@ :model/Collection normal-collection {:name "Normal"} :model/Document document {:collection_id (u/the-id remote-sync-collection)}] (#'impl/sync-objects! (t/instant) {:by-entity-id {"Document" #{(:entity_id document)}}}) - (let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Document" :model_id (:id document))] (is (= "synced" (:status initial-entry))) (events/publish-event! :event/document-update {:object (assoc document :collection_id (:id normal-collection)) :user-id (mt/user->id :rasta)}) - (let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Document" :model_id (:id document))] (is (= "removed" (:status update-entry))) (is (= (:id initial-entry) (:id update-entry)))))))) @@ -637,7 +631,6 @@ (events/publish-event! :event/collection-update {:object (assoc collection :is_remote_synced false) :user-id (mt/user->id :rasta)}) - (let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Collection" :model_id (:id collection))] (is (nil? update-entry))))))) @@ -828,21 +821,17 @@ :model/Document doc {:name "Test Doc" :collection_id (:id remote-sync-collection)}] (t2/delete! :model/RemoteSyncObject) - ;; Trash the doc (t2/update! :model/Document (:id doc) {:archived true}) (events/publish-event! :event/document-update {:object (assoc doc :archived true) :user-id (mt/user->id :rasta)}) - (let [soft-delete-entry (t2/select-one :model/RemoteSyncObject :model_type "Document" :model_id (:id doc))] (is (= "delete" (:status soft-delete-entry)))) - ;; Permanently delete from trash (t2/delete! :model/Document (:id doc)) (events/publish-event! :event/document-delete {:object doc :user-id (mt/user->id :rasta)}) - ;; The remote sync object entry should still exist with delete status ;; so the collection remains dirty (let [hard-delete-entry (t2/select-one :model/RemoteSyncObject :model_type "Document" :model_id (:id doc))] diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/impl_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/impl_test.clj index 52b873780e94..7cddb333304e 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/impl_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/impl_test.clj @@ -108,7 +108,6 @@ :error :metabase-enterprise.serialization.v2.load/not-found})] (is (= "Import failed: Database 'clickhouse' does not exist on this instance. Make sure all referenced databases and other dependencies are set up before importing." (impl/source-error-message e))))) - (testing "source-error-message produces helpful message for FK database-not-found errors" (let [cause (ex-info "table id present, but database not found: [clickhouse nil some_table]" {:table-id ["clickhouse" nil "some_table"]}) @@ -225,23 +224,19 @@ export-result (impl/export! (source.p/snapshot mock-main) (:id export-task) "Test export")] (remote-sync.task/complete-sync-task! (:id export-task)) (is (= :success (:status export-result))) - (let [files-after-export (get @(:files-atom mock-main) "test-branch")] (is (map? files-after-export)) (is (not-empty files-after-export)) (is (some #(str/includes? % "collection") (keys files-after-export))) (is (some #(str/includes? % "card") (keys files-after-export)))) - (t2/delete! :model/RemoteSyncTask :id (:id export-task)) (let [import-task (t2/with-connection [_conn (app-db/app-db) (t2/insert-returning-instance! :model/RemoteSyncTask {:sync_task_type "import" :initiated_by (mt/user->id :rasta)})]) import-result (impl/import! (source.p/snapshot mock-main) (:id import-task))] (remote-sync.task/complete-sync-task! (:id import-task)) (is (= :success (:status import-result))) (is (= "Successfully reloaded from git repository" (:message import-result))) - (is (t2/exists? :model/Collection :id coll-id)) (is (t2/exists? :model/Card :id card-id)) - (let [collection (t2/select-one :model/Collection :id coll-id) card (t2/select-one :model/Card :id card-id)] (is (= "Test Collection" (:name collection))) @@ -265,7 +260,6 @@ mock-main (test-helpers/create-mock-source :initial-files test-files :branch "test-branch") result (impl/import! (source.p/snapshot mock-main) (:id import-task))] (is (= :success (:status result))) - (is (t2/exists? :model/Card :id card1-id)) (is (not (t2/exists? :model/Collection :id coll2-id))) (is (not (t2/exists? :model/Card :id card2-id)))))))) diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/models/remote_sync_object_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/models/remote_sync_object_test.clj index 4eeabf5628d0..5cb9ae323727 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/models/remote_sync_object_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/models/remote_sync_object_test.clj @@ -331,7 +331,6 @@ (testing "when no dirty items exist" (is (false? (rs-object/dirty?))) (is (empty? (rs-object/dirty-objects)))) - (testing "when dirty items exist" (mt/with-temp [:model/Collection collection {:name "Test Collection" diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/models/remote_sync_task_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/models/remote_sync_task_test.clj index ae4445bac61c..d24e975948d9 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/models/remote_sync_task_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/models/remote_sync_task_test.clj @@ -64,7 +64,6 @@ (rst/update-progress! (:id task) 0.5) (let [updated-task (t2/select-one :model/RemoteSyncTask :id (:id task))] (is (= 0.5 (:progress updated-task)))) - (rst/update-progress! (:id task) 0.75) (let [updated-task (t2/select-one :model/RemoteSyncTask :id (:id task))] (is (= 0.75 (:progress updated-task)))) @@ -76,7 +75,6 @@ (rst/update-progress! (:id task) 0.0) (let [updated-task (t2/select-one :model/RemoteSyncTask :id (:id task))] (is (= 0.0 (:progress updated-task)))) - (rst/update-progress! (:id task) 1.0) (let [updated-task (t2/select-one :model/RemoteSyncTask :id (:id task))] (is (= 1.0 (:progress updated-task)))) @@ -181,13 +179,11 @@ (is (= 0.0 (:progress task))) (is (nil? (:ended_at task))) (is (some? (:started_at task))) - ;; Progress updates (rst/update-progress! (:id task) 0.3) (let [updated (t2/select-one :model/RemoteSyncTask :id (:id task))] (is (< (abs (- 0.3 (:progress updated))) 0.0001)) (is (nil? (:ended_at updated)))) - ;; Completion (rst/complete-sync-task! (:id task)) (let [completed (t2/select-one :model/RemoteSyncTask :id (:id task))] @@ -200,7 +196,6 @@ (let [task (rst/create-sync-task! "export" (mt/user->id :rasta))] ;; Progress before failure (rst/update-progress! (:id task) 0.7) - ;; Failure (let [error-msg "Network connection lost"] (rst/fail-sync-task! (:id task) error-msg) diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/permissions_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/permissions_test.clj index 4b4ac713931a..6a65367e60e0 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/permissions_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/permissions_test.clj @@ -252,7 +252,6 @@ (testing "remote-synced-collection? returns false for tenant collections by default" (is (false? (collections/remote-synced-collection? tenant-coll)) "Tenant collection should NOT be remote-synced by default")) - (testing "Tenant collections are editable by superuser by default" (mt/with-current-user (mt/user->id :crowberto) (is (true? (mi/can-write? tenant-coll)) @@ -270,7 +269,6 @@ (testing "remote-synced-collection? returns true when is_remote_synced flag is set" (is (true? (collections/remote-synced-collection? tenant-coll)) "Tenant collection should be remote-synced when flag is set")) - (testing "Tenant collections are NOT editable by superuser when remote-sync-type is read-only" (mt/with-current-user (mt/user->id :crowberto) (is (false? (mi/can-write? tenant-coll)) @@ -288,7 +286,6 @@ (testing "remote-synced-collection? returns true when is_remote_synced flag is set" (is (true? (collections/remote-synced-collection? tenant-coll)) "Tenant collection should be remote-synced when flag is set")) - (testing "Tenant collections ARE editable by superuser when remote-sync-type is read-write" (mt/with-current-user (mt/user->id :crowberto) (is (true? (mi/can-write? tenant-coll)) @@ -311,16 +308,13 @@ (testing "Parent tenant collection is remote-synced" (is (true? (collections/remote-synced-collection? parent-coll)) "Parent tenant collection should be remote-synced")) - (testing "Child tenant collection is remote-synced" (is (true? (collections/remote-synced-collection? child-coll)) "Child tenant collection should be remote-synced")) - (testing "Parent tenant collection is not editable by superuser" (mt/with-current-user (mt/user->id :crowberto) (is (false? (mi/can-write? parent-coll)) "Parent tenant collection should not be writable"))) - (testing "Child tenant collection is not editable by superuser" (mt/with-current-user (mt/user->id :crowberto) (is (false? (mi/can-write? child-coll)) @@ -344,24 +338,19 @@ (testing "Remote-synced tenant collection is remote-synced" (is (true? (collections/remote-synced-collection? remote-synced-tenant)) "Remote-synced tenant collection should be remote-synced")) - (testing "Regular tenant collection is NOT remote-synced" (is (false? (collections/remote-synced-collection? regular-tenant)) "Regular tenant collection should NOT be remote-synced")) - (testing "Regular collection is NOT remote-synced" (is (false? (collections/remote-synced-collection? regular-coll)) "Regular collection should NOT be remote-synced")) - (mt/with-current-user (mt/user->id :crowberto) (testing "Remote-synced tenant collection is not editable by superuser" (is (false? (mi/can-write? remote-synced-tenant)) "Remote-synced tenant collection should not be writable")) - (testing "Regular tenant collection is editable by superuser" (is (true? (mi/can-write? regular-tenant)) "Regular tenant collection should be writable")) - (testing "Regular collection remains editable by superuser" (is (true? (mi/can-write? regular-coll)) "Regular collection should be writable"))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/settings_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/settings_test.clj index 07a6e41d368d..0bde205882eb 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/settings_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/settings_test.clj @@ -70,7 +70,6 @@ (is (false? @git-check-called?) "Git validation should not be called for non-git settings") (is (false? (settings/remote-sync-transforms))) (is (false? (settings/remote-sync-auto-import)))))))) - (testing "Partial updates with git-related settings do trigger git validation" (let [git-check-called? (atom false)] (with-redefs [settings/check-git-settings! (fn [_] @@ -84,7 +83,6 @@ (settings/check-and-update-remote-settings! {:remote-sync-type :read-write}) (is (true? @git-check-called?) "Git validation should be called when updating type") (is (= :read-write (settings/remote-sync-type)))) - (testing "Updating remote-sync-branch triggers git validation" (reset! git-check-called? false) (settings/check-and-update-remote-settings! {:remote-sync-branch "develop"}) diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/source/git_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/source/git_test.clj index 81cca3cbdff1..2b62a6ef7739 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/source/git_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/source/git_test.clj @@ -48,7 +48,6 @@ full-path (io/file work-tree path)] (io/make-parents full-path) (spit full-path content) - (-> (.add git) (.addFilepattern path) (.call)))) @@ -60,7 +59,6 @@ (git-working-checkout! source branch true) (git-working-add! source (str "file-in-" branch ".txt") (str "File in " branch)) (git-working-commit! source (str "Init branch " branch)) - (git-working-checkout! source initial-branch false))) (defn- init-remote! @@ -73,12 +71,9 @@ remote {:git git}] (doseq [[path content] files] (git-working-add! remote path content)) - (git-working-commit! remote "Initial commit") - (doseq [branch branches] (git-working-create-branch! remote branch)) - remote)) (defn- ->source! @@ -152,7 +147,6 @@ (is (= "File in master" (source.p/read-file master-snap "master.txt"))) (is (= "File in subdir" (source.p/read-file master-snap "subdir/path.txt"))) (is (nil? (source.p/read-file master-snap "file-in-branch-1.txt")))) - (testing "Reading branch-1" (is (= "File in master" (source.p/read-file branch-1 "master.txt"))) (is (= "File in branch-1" (source.p/read-file branch-1 "file-in-branch-1.txt"))) @@ -186,12 +180,10 @@ "master.txt" "master2.txt"] (source.p/list-files master-snap))) - (is (= "Updated master content" (source.p/read-file master-snap "master.txt"))) (is (= "File 2 in master" (source.p/read-file master-snap "master2.txt"))) (is (= "Updated subdir content" (source.p/read-file master-snap (str subdir-path "path.txt")))) (is (= "Updated subdir content 3" (source.p/read-file master-snap (str subdir-path "path3.txt"))))) - (testing "Check remote repo directly" (is (= "Updated master content" (git/read-file (assoc remote :version "master") "master.txt"))) (is (= [(str subdir-path "path.txt") @@ -202,7 +194,6 @@ "master2.txt"] (git/list-files (assoc remote :version "master")))) (is (= ["Update 1" "Initial commit"] (map :message (git/log (assoc remote :branch "master"))))))) - (testing "Writing only to collections/ removes all other collection files" (source.p/write-files! (source.p/snapshot master) "Update 2" [{:path (str thirddir-path "path.txt") :content "Only third dir content"}]) (is (= [(str thirddir-path "path.txt") @@ -224,50 +215,38 @@ "subdir/path.txt" "File in subdir"} :branches ["branch-1" "branch-2"]) new-branch (->source! "new-branch" remote)] - (testing "Initial clone is the same" (is (= ["Initial commit"] (map :message (git/log master)))) (is (= ["Initial commit"] (map :message (git/log (assoc remote :branch "master"))))) - ;; Add an extra commit to remote (git-working-add! remote "additional-file.txt" "Additional file content") (git-working-commit! remote "Added additional file") - (testing "Source is behind remote" (is (= ["Initial commit"] (map :message (git/log master)))) (is (= ["Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "master")))))) - (testing "After fetch, source is up to date" (git/fetch! master) (is (= ["Added additional file" "Initial commit"] (map :message (git/log master))))) - (testing "Writing a file to source and pushing back to remote when there is new content on remote" ;; Make source be behind again (git-working-add! remote "only-on-remote.txt" "Initially on remote") (git-working-commit! remote "Only on remote") - (source.p/write-files! (source.p/snapshot master) "Added to source" [{:path "initially-source.txt" :content "Initially on source"}]) - (testing "Remote has the new commit with just the files committed, but only version is in history" (is (= ["Added to source" "Only on remote" "Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "master"))))) (is (= ["additional-file.txt" "initially-source.txt" "master.txt" "only-on-remote.txt" "subdir/path.txt"] (git/list-files (assoc remote :version "master")))) (is (= "Initially on source" (git/read-file (assoc remote :version "master") "initially-source.txt")))) - (testing "Source has the same history" (is (= (map :message (git/log (assoc remote :branch "master"))) (map :message (git/log master)))))) - (testing "Writing to a branch local has not seen (but remote has) adds it to the history on remote" (git-working-checkout! remote "new-branch" true) (git-working-add! remote "new-branch-file.txt" "Initially on remote") (git-working-add! remote "new-branch-remote.txt" "Initially on remote") (git-working-commit! remote "New-branch on remote") - (is (= ["New-branch on remote" "Added to source" "Only on remote" "Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "new-branch"))))) (is (nil? (git/log new-branch))) - (source.p/write-files! (source.p/snapshot new-branch) "New-branch on source" [{:path "new-branch-source.txt" :content "Initially on source"} {:path "new-branch-file.txt" :content "Updated on source"}]) - (is (= ["New-branch on source" "New-branch on remote" "Added to source" "Only on remote" "Added additional file" "Initial commit"] (map :message (git/log (assoc remote :branch "new-branch")))))))))) (deftest git-source-using-commit-ref @@ -276,7 +255,6 @@ :files {"master.txt" "File in master" "subdir/path.txt" "File in subdir"}) old-master (source.p/snapshot master)] - (source.p/write-files! (source.p/snapshot master) "Update file" [{:path "master.txt" :content "Updated file in master"} {:path "new-file.txt" :content "New file in master"}]) (is (= "File in master" (source.p/read-file old-master "master.txt"))) @@ -293,7 +271,6 @@ (is (= 40 (count initial-version)) "version should be a full SHA-1 hash (40 characters)") (is (= (git/commit-sha master "master") initial-version) "version should match the commit id for the branch") - (testing "version changes after writing files" (source.p/write-files! (source.p/snapshot master) "Update file" [{:path "master.txt" :content "Updated content"}]) (let [new-version (source.p/version (source.p/snapshot master))] @@ -301,12 +278,10 @@ (is (= 40 (count new-version)) "new version should also be a full SHA-1 hash") (is (= (git/commit-sha master "master") new-version) "new version should match the new commit id"))) - (testing "version is consistent across multiple calls" (let [version-1 (source.p/version (source.p/snapshot master)) version-2 (source.p/version (source.p/snapshot master))] (is (= version-1 version-2) "version should be consistent without changes"))) - (testing "version differs for different branches" (git-working-create-branch! remote "branch-1") (let [branch-1 (->source! "branch-1" remote) @@ -314,7 +289,6 @@ branch-version (source.p/version (source.p/snapshot branch-1))] (is (not= master-version branch-version) "different branches should have different versions"))) - (testing "version matches specific commit ref" (let [commit-ref (git/commit-sha master "master") source-with-ref (->source! commit-ref remote)] @@ -355,7 +329,6 @@ (is (contains? files (str old-col-path "cards/card1.yaml")) "Written collection files should remain") (is (contains? files "snippets/old_snippet.yaml") "Written snippet file should remain") (is (contains? files "unmanaged/keep_me.txt") "Unmanaged files should be untouched"))) - (testing "Entity moved between collections removes files from old collection" (source.p/write-files! (source.p/snapshot master) "Move card to new collection" [{:path (str new-col-path "cards/card1.yaml") :content "Card moved to new col"} diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/source/ingestable_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/source/ingestable_test.clj index eea1c77469d9..20b497319558 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/source/ingestable_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/source/ingestable_test.clj @@ -17,20 +17,17 @@ (testing "IngestableSnapshot wraps a snapshot and provides Ingestable interface" (let [mock-source (test-helpers/create-mock-source) ingestable (ingestable/->IngestableSnapshot (source.p/snapshot mock-source) (atom nil) (atom []))] - (testing "ingest-list returns list of serdes paths" (let [paths (serialization/ingest-list ingestable)] (is (seq paths) "Should return non-empty list of paths") (is (every? vector? paths) "Each path should be a vector") (is (every? #(every? map? %) paths) "Each element in path should be a map"))) - (testing "ingest-one returns entity for a given path" (let [paths (serialization/ingest-list ingestable) first-path (first paths) entity (serialization/ingest-one ingestable first-path)] (is (map? entity) "Should return a map") (is (contains? entity :serdes/meta) "Should have serdes/meta key"))) - (testing "cache is populated after first ingest-list call" (let [ingestable (ingestable/->IngestableSnapshot (source.p/snapshot mock-source) (atom nil) (atom []))] (is (nil? @(:cache ingestable)) "Cache should be empty initially") @@ -45,12 +42,10 @@ calls (atom []) callback (fn [_ path] (swap! calls conj path)) wrapped (ingestable/->CallbackIngestable base-ingestable callback)] - (testing "ingest-list delegates to wrapped ingestable" (let [base-paths (serialization/ingest-list base-ingestable) wrapped-paths (serialization/ingest-list wrapped)] (is (= base-paths wrapped-paths) "Should return same paths as base ingestable"))) - (testing "ingest-one calls callback after loading entity" (let [paths (serialization/ingest-list wrapped) first-path (first paths)] @@ -58,7 +53,6 @@ (serialization/ingest-one wrapped first-path) (is (= 1 (count @calls)) "Callback should be called once") (is (= first-path (first @calls)) "Callback should receive the path"))) - (testing "callback is called for each ingest-one call" (let [paths (serialization/ingest-list wrapped)] (reset! calls []) @@ -81,22 +75,17 @@ mock-source (test-helpers/create-mock-source) base-ingestable (ingestable/->IngestableSnapshot (source.p/snapshot mock-source) (atom nil) (atom [])) normalize 100] - (testing "creates a CallbackIngestable" (let [wrapped (ingestable/wrap-progress-ingestable task-id normalize base-ingestable)] (is (instance? metabase_enterprise.remote_sync.source.ingestable.CallbackIngestable wrapped) "Should return CallbackIngestable instance"))) - (testing "updates task progress in database as items are ingested" (let [wrapped (ingestable/wrap-progress-ingestable task-id normalize base-ingestable) paths (serialization/ingest-list wrapped) total-paths (count paths)] - (is (seq paths) "Should have paths to ingest") - (let [initial-task (t2/select-one :model/RemoteSyncTask :id task-id)] (is (nil? (:progress initial-task)) "Progress should be nil initially")) - (serialization/ingest-one wrapped (first paths)) (let [task-after-first (t2/select-one :model/RemoteSyncTask :id task-id)] (is (some? (:progress task-after-first)) "Progress should be updated after first item") @@ -104,19 +93,15 @@ "Progress should reflect one item ingested") (is (some? (:last_progress_report_at task-after-first)) "last_progress_report_at should be set")) - (serialization/ingest-one wrapped (second paths)) (let [task-after-second (t2/select-one :model/RemoteSyncTask :id task-id)] (is (< (abs (- (:progress task-after-second) (double (* (/ 2 total-paths) normalize)))) 0.01) "Progress should reflect two items ingested")))) - (testing "progress reaches normalize value when all items ingested" (let [wrapped (ingestable/wrap-progress-ingestable task-id normalize base-ingestable) paths (serialization/ingest-list wrapped)] - (doseq [path paths] (serialization/ingest-one wrapped path)) - (let [final-task (t2/select-one :model/RemoteSyncTask :id task-id)] (is (< (abs (- (:progress final-task) (double normalize))) 0.01) "Progress should equal normalize value when all items ingested")))))))) @@ -125,12 +110,10 @@ (testing "RootDependencyIngestable filters items based on root dependencies" (let [mock-source (test-helpers/create-mock-source) base-ingestable (ingestable/->IngestableSnapshot (source.p/snapshot mock-source) (atom nil) (atom []))] - (testing "with no root dependencies, returns empty list" (let [wrapped (ingestable/wrap-root-dep-ingestable [] base-ingestable) paths (serialization/ingest-list wrapped)] (is (empty? paths) "Should return empty list when no root dependencies"))) - (testing "ingest-one delegates to wrapped ingestable" (let [paths (serialization/ingest-list base-ingestable) first-path (first paths) @@ -138,7 +121,6 @@ wrapped (ingestable/wrap-root-dep-ingestable root-deps base-ingestable) entity (serialization/ingest-one wrapped first-path)] (is (map? entity) "Should return entity map from wrapped ingestable"))) - (testing "has dep-cache atom" (let [root-deps [{:model "Collection" :id "M-Q4pcV0qkiyJ0kiSWECl"}] wrapped (ingestable/wrap-root-dep-ingestable root-deps base-ingestable)] @@ -153,22 +135,18 @@ bad-yaml}} snapshot (source.p/snapshot (test-helpers/create-mock-source :initial-files files)) ingestable (ingestable/->IngestableSnapshot snapshot (atom nil) (atom []))] - (testing "ingest-list succeeds, skipping the bad file" (let [paths (serialization/ingest-list ingestable)] (is (seq paths) "Should return paths for valid files"))) - (testing "ingest-errors returns the parse failure" (let [errors (serialization/ingest-errors ingestable)] (is (= 1 (count errors))) (is (instance? Exception (first errors))))))) - (testing "ingest-errors returns [] when all files parse successfully" (let [snapshot (source.p/snapshot (test-helpers/create-mock-source)) ingestable (ingestable/->IngestableSnapshot snapshot (atom nil) (atom []))] (serialization/ingest-list ingestable) (is (= [] (serialization/ingest-errors ingestable))))) - (testing "ingest-errors returns [] before cache is populated" (let [bad-yaml "name: Bad Card\ndataset_query: [invalid\n" files {"main" {"collections/coll01xxxxxxxxxxxxx_test/coll01xxxxxxxxxxxxx_test.yaml" @@ -179,7 +157,6 @@ ingestable (ingestable/->IngestableSnapshot snapshot (atom nil) (atom []))] (is (= [] (serialization/ingest-errors ingestable)) "Before ingest-list is called, ingest-errors should return [] not the real errors"))) - (testing "multiple bad files produce multiple errors" (let [bad-yaml-1 "name: Bad1\ndataset_query: [invalid\n" bad-yaml-2 "name: Bad2\ndataset_query: {broken\n" @@ -194,7 +171,6 @@ (serialization/ingest-list ingestable) (is (= 2 (count (serialization/ingest-errors ingestable))) "Each unparseable file should produce a separate error"))) - (testing "wrapper ingestables delegate ingest-errors" (let [bad-yaml "name: Bad\ndataset_query: [invalid\n" files {"main" {"collections/coll01xxxxxxxxxxxxx_test/coll01xxxxxxxxxxxxx_test.yaml" @@ -207,9 +183,7 @@ root-dep (ingestable/wrap-root-dep-ingestable [{:model "Collection" :id "coll01xxxxxxxxxxxxx"}] base)] ;; Trigger cache population (serialization/ingest-list base) - (testing "CallbackIngestable delegates" (is (= 1 (count (serialization/ingest-errors callback))))) - (testing "RootDependencyIngestable delegates" (is (= 1 (count (serialization/ingest-errors root-dep)))))))) diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/source_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/source_test.clj index 0f77acc5cf43..16f74a821236 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/source_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/source_test.clj @@ -62,27 +62,21 @@ mock-source (->MockSource written-files) test-entities [(create-test-entity "test-id-1" "entity-one" "Collection") (create-test-entity "test-id-2" "entity-two" "Card")]] - (is (= "mock-written-version" (source/store! test-entities (source.p/snapshot mock-source) task-id "Test commit message"))) - (testing "write-files! was called with correct message" (is (= "Test commit message" (:message @written-files)))) - (testing "write-files! was called with correct number of files" (is (= 2 (count (:files @written-files))))) - (testing "each file has path and content" (doseq [file (:files @written-files)] (is (contains? file :path) "File should have :path") (is (contains? file :content) "File should have :content") (is (string? (:path file)) "Path should be a string") (is (string? (:content file)) "Content should be a string"))) - (testing "file paths end with .yaml" (doseq [file (:files @written-files)] (is (str/ends-with? (:path file) ".yaml") "File paths should end with .yaml"))) - (testing "file content is valid YAML containing entity data" (doseq [file (:files @written-files)] (is (str/includes? (:content file) "serdes/meta") @@ -104,12 +98,9 @@ test-entities [(create-test-entity "test-id-1" "entity-one" "Collection") (create-test-entity "test-id-2" "entity-two" "Card") (create-test-entity "test-id-3" "entity-three" "Dashboard")]] - (let [initial-task (t2/select-one :model/RemoteSyncTask :id task-id)] (is (nil? (:progress initial-task)) "Progress should be nil initially")) - (source/store! test-entities (source.p/snapshot mock-source) task-id "Test commit") - (let [final-task (t2/select-one :model/RemoteSyncTask :id task-id)] (is (some? (:progress final-task)) "Progress should be updated after store!") (is (> (:progress final-task) 0.3) "Progress should be greater than 0.3") @@ -127,12 +118,9 @@ :initiated_by (:id user)}] (let [written-files (atom nil) mock-source (->MockSource written-files)] - (source/store! [] (source.p/snapshot mock-source) task-id "Empty commit") - (testing "write-files! was called even with empty stream" (is (some? @written-files))) - (testing "files list is empty" (is (empty? (:files @written-files)))))))) diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/spec_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/spec_test.clj index a98a02b1b437..ea4b58fdf5b1 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/spec_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/spec_test.clj @@ -90,13 +90,11 @@ (when-let [cf (:cascade-filter spec)] (is (map? cf) ":cascade-filter should be a map when present"))))) - (testing "children-specs derives the correct children for Table" (let [children (spec/children-specs :model/Table)] (is (= 1 (count children))) (is (= #{:model/Field} (into #{} (map :model-key) children))))) - (testing "children-specs returns empty for models with no children" (is (empty? (spec/children-specs :model/Card))))) @@ -160,7 +158,6 @@ (is (contains? excluded "TransformTag")) (is (not (contains? excluded "Card"))) (is (not (contains? excluded "Dashboard")))))) - (testing "excluded-model-types when transforms enabled" (mt/with-temporary-setting-values [remote-sync-transforms true] (let [excluded (spec/excluded-model-types)] @@ -172,11 +169,9 @@ (deftest spec-enabled?-test (testing "spec-enabled? with always-enabled spec" (is (true? (spec/spec-enabled? {:enabled? true})))) - (testing "spec-enabled? with setting-based spec" (mt/with-temporary-setting-values [remote-sync-transforms false] (is (false? (spec/spec-enabled? {:enabled? :remote-sync-transforms})))) - (mt/with-temporary-setting-values [remote-sync-transforms true] (is (true? (spec/spec-enabled? {:enabled? :remote-sync-transforms})))))) @@ -188,7 +183,6 @@ (is (contains? enabled :model/Dashboard)) (is (not (contains? enabled :model/Transform))) (is (not (contains? enabled :model/TransformTag))))) - (mt/with-temporary-setting-values [remote-sync-transforms true] (let [enabled (spec/enabled-specs)] (is (contains? enabled :model/Card)) @@ -201,15 +195,12 @@ (testing "determine-status for create event" (let [spec (spec/spec-for-model-key :model/Card)] (is (= "create" (spec/determine-status spec :event/card-create {:archived false}))))) - (testing "determine-status for update event" (let [spec (spec/spec-for-model-key :model/Card)] (is (= "update" (spec/determine-status spec :event/card-update {:archived false}))))) - (testing "determine-status for delete event" (let [spec (spec/spec-for-model-key :model/Card)] (is (= "delete" (spec/determine-status spec :event/card-delete {:archived false}))))) - (testing "determine-status for archived object returns delete" (let [spec (spec/spec-for-model-key :model/Card)] (is (= "delete" (spec/determine-status spec :event/card-update {:archived true})))))) @@ -232,7 +223,6 @@ fields (spec/build-sync-object-fields spec details)] (is (= "My Dashboard" (:model_name fields))) (is (= 123 (:model_collection_id fields))))) - (testing "build-sync-object-fields with transform function" (let [spec (spec/spec-for-model-key :model/Card) details {:name "My Card" :collection_id 456 :display :table} @@ -240,7 +230,6 @@ (is (= "My Card" (:model_name fields))) (is (= 456 (:model_collection_id fields))) (is (= "table" (:model_display fields))))) - (testing "build-sync-object-fields with nil details returns nil" (let [spec (spec/spec-for-model-key :model/Card)] (is (nil? (spec/build-sync-object-fields spec nil)))))) @@ -255,7 +244,6 @@ (spec/fields-for-sync "Dashboard"))) (is (= [:name :collection_id] (spec/fields-for-sync "NativeQuerySnippet")))) - (testing "fields-for-sync returns default for unknown type" (is (= [:id :name :collection_id] (spec/fields-for-sync "UnknownModel"))))) @@ -274,13 +262,10 @@ (deftest export-scope-required-for-certain-models-test (testing "Collection spec has :root-collections export-scope" (is (= :root-collections (:export-scope (spec/spec-for-model-key :model/Collection))))) - (testing "Transform spec has :root-only export-scope" (is (= :root-only (:export-scope (spec/spec-for-model-key :model/Transform))))) - (testing "TransformTag spec has :all export-scope" (is (= :all (:export-scope (spec/spec-for-model-key :model/TransformTag))))) - (testing "Other collection-based models have no export-scope (defaults to :derived)" (is (nil? (:export-scope (spec/spec-for-model-key :model/Card)))) (is (nil? (:export-scope (spec/spec-for-model-key :model/Dashboard)))))) @@ -291,7 +276,6 @@ (testing "query-export-roots with :collection eligibility and :derived scope returns nil" (let [card-spec (spec/spec-for-model-key :model/Card)] (is (nil? (spec/query-export-roots card-spec))))) - (testing "query-export-roots with :collection eligibility and :root-collections scope queries collections" ;; This test verifies the multimethod dispatches correctly - actual database queries ;; are tested in impl_test.clj integration tests @@ -311,7 +295,6 @@ (testing "query-export-roots with :published-table eligibility returns nil (derived)" (let [table-spec (spec/spec-for-model-key :model/Table)] (is (nil? (spec/query-export-roots table-spec))))) - (testing "query-export-roots with :parent-table eligibility returns nil (derived)" (let [field-spec (spec/spec-for-model-key :model/Field) segment-spec (spec/spec-for-model-key :model/Segment) @@ -343,7 +326,6 @@ (mt/with-temp [:model/Collection _ {:name "Library" :type "library" :is_remote_synced true :location "/"}] (is (false? (spec/model-editable? :model/NativeQuerySnippet {})) "Snippets should NOT be editable when library is synced and mode is read-only")))) - (testing "returns true when library is NOT synced even in read-only mode" (mt/with-temporary-setting-values [remote-sync-type :read-only] (mt/with-temp [:model/Collection _ {:name "Library" :type "library" :is_remote_synced false :location "/"}] @@ -357,7 +339,6 @@ remote-sync-transforms true] (is (false? (spec/model-editable? :model/Transform {})) "Transforms should NOT be editable when transforms setting is enabled and mode is read-only"))) - (testing "returns true when setting is disabled even in read-only mode" (mt/with-temporary-setting-values [remote-sync-type :read-only remote-sync-transforms false] @@ -371,7 +352,6 @@ (mt/with-temp [:model/Collection {coll-id :id} {:name "Synced Collection" :is_remote_synced true :location "/"}] (is (false? (spec/model-editable? :model/Card {:collection_id coll-id})) "Cards in synced collections should NOT be editable in read-only mode")))) - (testing "returns true when card is in non-synced collection even in read-only mode" (mt/with-temporary-setting-values [remote-sync-type :read-only] (mt/with-temp [:model/Collection {coll-id :id} {:name "Normal Collection" :is_remote_synced false :location "/"}] @@ -384,7 +364,6 @@ remote-sync-transforms true] (is (false? (spec/model-editable? :model/Transform nil)) "Transforms with nil instance should check setting-based eligibility")) - (mt/with-temporary-setting-values [remote-sync-type :read-only] (mt/with-temp [:model/Collection _ {:name "Library" :type "library" :is_remote_synced true :location "/"}] (is (false? (spec/model-editable? :model/NativeQuerySnippet nil)) @@ -396,12 +375,10 @@ (testing "batch-check-eligibility with :library-synced eligibility" (let [spec (spec/spec-for-model-key :model/NativeQuerySnippet) instances [{:id 1} {:id 2} {:id 3}]] - (testing "returns true for all when library is synced" (mt/with-temp [:model/Collection _ {:name "Library" :type "library" :is_remote_synced true :location "/"}] (let [result (spec/batch-check-eligibility spec instances)] (is (= {1 true, 2 true, 3 true} result))))) - (testing "returns false for all when library is not synced" (mt/with-temp [:model/Collection _ {:name "Library" :type "library" :is_remote_synced false :location "/"}] (let [result (spec/batch-check-eligibility spec instances)] @@ -445,7 +422,6 @@ (let [instances [{:id 1} {:id 2} {:id 3}] result (spec/batch-model-editable? :model/NativeQuerySnippet instances)] (is (= {1 false, 2 false, 3 false} result)))))) - (testing "returns true for all when library is not synced" (mt/with-temporary-setting-values [remote-sync-type :read-only] (mt/with-temp [:model/Collection _ {:name "Library" :type "library" :is_remote_synced false :location "/"}] @@ -460,11 +436,9 @@ (is (= {:foo :bar} (spec/export-conditions {:export-conditions {:foo :bar} :conditions {:baz :qux}})))) - (testing "export-conditions falls back to :conditions when :export-conditions absent" (is (= {:baz :qux} (spec/export-conditions {:conditions {:baz :qux}})))) - (testing "export-conditions returns nil when neither key present" (is (nil? (spec/export-conditions {}))))) @@ -473,11 +447,9 @@ (is (= {:foo :bar} (spec/removal-conditions {:removal-conditions {:foo :bar} :conditions {:baz :qux}})))) - (testing "removal-conditions falls back to :conditions when :removal-conditions absent" (is (= {:baz :qux} (spec/removal-conditions {:conditions {:baz :qux}})))) - (testing "removal-conditions returns nil when neither key present" (is (nil? (spec/removal-conditions {}))))) @@ -491,11 +463,9 @@ (is (= {:entity_id [:not= transforms-python/builtin-entity-id]} (:removal-conditions spec)) "PythonLibrary should have :removal-conditions protecting builtin entity"))) - (testing "export-conditions returns nil for PythonLibrary (no export filtering)" (let [spec (spec/spec-for-model-key :model/PythonLibrary)] (is (nil? (spec/export-conditions spec))))) - (testing "removal-conditions returns the builtin protection for PythonLibrary" (let [spec (spec/spec-for-model-key :model/PythonLibrary)] (is (= {:entity_id [:not= transforms-python/builtin-entity-id]} diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/task/table_cleanup_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/task/table_cleanup_test.clj index ef40716f027d..3b6eab235d1d 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/task/table_cleanup_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/task/table_cleanup_test.clj @@ -61,12 +61,10 @@ :ended_at (t/minus now (t/days 60))}] ;; Verify all tasks were created (is (= 6 (t2/count :model/RemoteSyncTask))) - ;; Run cleanup (let [deleted-count (#'table-cleanup/trim-remote-sync-tasks!)] ;; Should delete tasks at 31 and 60 days (2 tasks) (is (= 2 deleted-count))) - ;; Verify only recent tasks remain (is (= 4 (t2/count :model/RemoteSyncTask))) (is (some? (t2/select-one :model/RemoteSyncTask :id (:id task-today)))) @@ -106,10 +104,8 @@ :started_at (t/minus now (t/days 29)) :ended_at (t/minus now (t/days 29))}] (is (= 3 (t2/count :model/RemoteSyncTask))) - ;; Run cleanup (let [deleted-count (#'table-cleanup/trim-remote-sync-tasks!)] (is (= 0 deleted-count))) - ;; All tasks should remain (is (= 3 (t2/count :model/RemoteSyncTask))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/remote_sync/transforms_test.clj b/enterprise/backend/test/metabase_enterprise/remote_sync/transforms_test.clj index 9a5136410474..ab39fb8c8b58 100644 --- a/enterprise/backend/test/metabase_enterprise/remote_sync/transforms_test.clj +++ b/enterprise/backend/test/metabase_enterprise/remote_sync/transforms_test.clj @@ -453,7 +453,6 @@ is_sample: false :location "/"}] (is (contains? (set (spec/all-syncable-collection-ids)) transforms-coll-id) "Transforms-namespace collection should be included when setting is enabled"))))) - (testing "Transforms collections are NOT included when setting is disabled" (mt/with-premium-features #{:transforms-basic} (mt/with-temporary-setting-values [remote-sync-transforms false diff --git a/enterprise/backend/test/metabase_enterprise/replacement/api_test.clj b/enterprise/backend/test/metabase_enterprise/replacement/api_test.clj index d5c37e848b52..e11a3ad6ba1c 100644 --- a/enterprise/backend/test/metabase_enterprise/replacement/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/replacement/api_test.clj @@ -130,13 +130,11 @@ :model/DashboardCard _ {:dashboard_id dashboard-id :card_id mbql-child-1-id}] - (mt/with-model-cleanup [:model/ReplacementRun :model/Dependency] ;; Populate dependencies via events (doseq [card [old-model mbql-child-1 mbql-child-2 native-child grandchild grandchild-native]] (events/publish-event! :event/card-create {:object card :user-id (mt/user->id :crowberto)})) (deps.test/synchronously-run-backfill!) - (let [response (mt/user-http-request :crowberto :post 202 "ee/replacement/replace-source" {:source_entity_id old-id :source_entity_type :card @@ -145,32 +143,26 @@ run-id (:run_id response) final (poll-run run-id)] (is (= "succeeded" (:status final))) - (testing "MBQL children have updated source-card" (doseq [[label card-id] [["MBQL Child 1" mbql-child-1-id] ["MBQL Child 2" mbql-child-2-id]]] (testing label (let [q (t2/select-one-fn :dataset_query :model/Card :id card-id)] (is (= new-id (lib/primary-source-card-id q))))))) - (testing "Native child references new model in SQL" (let [q (t2/select-one-fn :dataset_query :model/Card :id native-child-id) sql (get-in q [:stages 0 :native])] (is (re-find (re-pattern (str "\\{\\{#" new-id "[^0-9]")) sql)) (is (not (re-find (re-pattern (str "\\{\\{#" old-id "[^0-9]")) sql))))) - (testing "Grandchild still references its direct parent" (let [q (t2/select-one-fn :dataset_query :model/Card :id grandchild-id)] (is (= mbql-child-1-id (lib/primary-source-card-id q))))) - (testing "Grandchild via native still references native child" (let [q (t2/select-one-fn :dataset_query :model/Card :id grandchild-native-id)] (is (= native-child-id (lib/primary-source-card-id q))))) - (testing "Transform's source query references new model" (let [src (t2/select-one-fn :source :model/Transform :id transform-id)] (is (= new-id (lib/primary-source-card-id (:query src)))))) - (testing "Dependencies point to new model" (let [deps-to-old (t2/select :model/Dependency :to_entity_type :card @@ -377,7 +369,6 @@ (doseq [card [model-card question-card]] (events/publish-event! :event/card-create {:object card :user-id (mt/user->id :crowberto)})) (deps.test/synchronously-run-backfill!) - (let [response (mt/user-http-request :crowberto :post 202 "ee/replacement/replace-model-with-transform" {:card_id model-id @@ -389,12 +380,9 @@ :target_collection_id nil}) run-id (:run_id response) final (poll-run run-id :timeout-ms 30000)] - (is (= "succeeded" (:status final))) - (testing "model is converted to a saved question" (is (= :question (t2/select-one-fn :type :model/Card :id model-id)))) - (testing "dependent question now references the output table" (let [question-query (t2/select-one-fn :dataset_query :model/Card :id question-id-id) transform (t2/select-one :model/Transform :name "Orders Transform") @@ -425,13 +413,10 @@ :database (mt/id)}}) run-id (:run_id response) final (poll-run run-id)] - (testing "run reaches failed status" (is (= "failed" (:status final)))) - (testing "error message is captured" (is (some? (:message final)))) - (testing "model is NOT converted to a question" (is (= :model (t2/select-one-fn :type :model/Card :id model-id)))))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/replacement/field_refs_test.clj b/enterprise/backend/test/metabase_enterprise/replacement/field_refs_test.clj index 9ee588da4431..c15ff6b2a31a 100644 --- a/enterprise/backend/test/metabase_enterprise/replacement/field_refs_test.clj +++ b/enterprise/backend/test/metabase_enterprise/replacement/field_refs_test.clj @@ -187,7 +187,6 @@ (testing "parameters should be upgraded" (is (=? [{:values_source_config {:value_field [:field "CATEGORY" {}]}}] (:parameters card)))))))) - (testing "should not update when there are no changes" (let [mp (mt/metadata-provider) query (lib/native-query mp "SELECT * FROM orders") diff --git a/enterprise/backend/test/metabase_enterprise/replacement/runner_test.clj b/enterprise/backend/test/metabase_enterprise/replacement/runner_test.clj index 9577079307ea..68eea3fd5fec 100644 --- a/enterprise/backend/test/metabase_enterprise/replacement/runner_test.clj +++ b/enterprise/backend/test/metabase_enterprise/replacement/runner_test.clj @@ -45,11 +45,9 @@ loaded (#'replacement.runner/bulk-load-metadata-for-entities! metadata-provider entities)] - (testing "returns map with all fetched entities" (is (map? loaded)) (is (= 4 (count loaded)))) - (testing "card entities are keyed by [:card id]" (is (contains? loaded [:card card-id-1])) (is (contains? loaded [:card card-id-2])) @@ -59,12 +57,10 @@ (is (= "Card 2" (:name card2))) (is (some? (:dataset_query card1))) (is (some? (:dataset_query card2))))) - (testing "table entities are keyed by [:table id]" (is (contains? loaded [:table table-id])) (let [table (get loaded [:table table-id])] (is (= "Custom Table" (:name table))))) - (testing "segment entities are keyed by [:segment id]" (is (contains? loaded [:segment segment-id])) (let [segment (get loaded [:segment segment-id])] @@ -94,7 +90,6 @@ entities) ;; After bulk loading, the source card should be in the metadata provider's cache source-card-meta (lib.metadata/card metadata-provider source-card-id)] - (testing "referenced cards are loaded into metadata provider cache" (is (some? source-card-meta)) (is (= "Source Card" (:name source-card-meta))) @@ -133,14 +128,12 @@ loaded (#'replacement.runner/bulk-load-metadata-for-entities! metadata-provider entities)] - (testing "handles cards, segments, measures, and dashboards in one batch" (is (= 4 (count loaded))) (is (contains? loaded [:card card-id])) (is (contains? loaded [:segment segment-id])) (is (contains? loaded [:measure measure-id])) (is (contains? loaded [:dashboard dashboard-id]))) - (testing "all entities have required fields" (is (some? (:dataset_query (get loaded [:card card-id])))) (is (some? (:definition (get loaded [:segment segment-id])))) @@ -154,7 +147,6 @@ loaded (#'replacement.runner/bulk-load-metadata-for-entities! metadata-provider entities)] - (testing "returns empty map for empty batch" (is (= {} loaded)))))) @@ -168,10 +160,8 @@ loaded (#'replacement.runner/bulk-load-metadata-for-entities! metadata-provider entities)] - (testing "dashboards are pre-loaded" (is (= [{:id "param1" :type :string/= :name "Test Param"}] (:parameters (get loaded [:dashboard dashboard-id]))))) - (testing "documents are not fetched (no-op entities)" (is (not (contains? loaded [:document 123])))))))) @@ -202,11 +192,9 @@ (events/publish-event! :event/card-create {:object old-card :user-id (mt/user->id :rasta)}) (events/publish-event! :event/card-create {:object child-card :user-id (mt/user->id :rasta)}) (deps.test/synchronously-run-backfill!) - (testing "child card initially points to old model" (is (= old-id (get-in (t2/select-one-fn :dataset_query :model/Card :id child-id) [:stages 0 :source-card])))) - (let [progress-log (atom []) progress (reify replacement.protocols/IRunnerProgress (set-total! [_ total] (swap! progress-log conj [:set-total total])) @@ -218,11 +206,9 @@ (fail-run! [_ _]))] #_{:clj-kondo/ignore [:unresolved-var]} (replacement.runner/run-swap-source! [:card old-id] [:card new-id] progress) - (testing "child card's source-card is updated to new model" (is (= new-id (get-in (t2/select-one-fn :dataset_query :model/Card :id child-id) [:stages 0 :source-card])))) - (testing "progress was tracked" (is (some #(= :set-total (first %)) @progress-log) "set-total! should have been called") @@ -261,7 +247,6 @@ (doseq [card [old-card child-1 child-2]] (events/publish-event! :event/card-create {:object card :user-id (mt/user->id :rasta)})) (deps.test/synchronously-run-backfill!) - (let [original-swap! replacement.source-swap/swap-source!] (with-redefs [replacement.source-swap/swap-source! (fn [entity object old-source new-source] @@ -273,11 +258,9 @@ [:card old-id] [:card new-id])))] (testing "failure details are in ex-data" (is (= 1 (count (:failures (ex-data ex)))))) - (testing "child-2 was still swapped successfully" (is (= new-id (get-in (t2/select-one-fn :dataset_query :model/Card :id child-2-id) [:stages 0 :source-card])))) - (testing "child-1 retains original source (swap failed)" (is (= old-id (get-in (t2/select-one-fn :dataset_query :model/Card :id child-1-id) [:stages 0 :source-card])))))))))))))) @@ -315,19 +298,15 @@ :semantic_type "type/CreationTimestamp" :base_type "type/DateTimeWithLocalTZ"}])} :where [:= :id card-id]}) - (#'replacement.runner/copy-model-metadata-overrides! card-id table-id) - (testing "Field records are updated with overrides from model metadata" (let [field-1 (t2/select-one :model/Field :id field-1-id) field-2 (t2/select-one :model/Field :id field-2-id)] (is (= "Order Total" (:display_name field-1))) (is (= "The total amount" (:description field-1))) (is (= :type/Currency (:semantic_type field-1))) - (is (= "Order Date" (:display_name field-2))) (is (= :type/CreationTimestamp (:semantic_type field-2))))) - (testing "FieldUserSettings are created so overrides survive sync" (let [fus-1 (t2/select-one :model/FieldUserSettings :field_id field-1-id) fus-2 (t2/select-one :model/FieldUserSettings :field_id field-2-id)] @@ -335,11 +314,9 @@ (is (= "Order Total" (:display_name fus-1))) (is (= "The total amount" (:description fus-1))) (is (= :type/Currency (:semantic_type fus-1))) - (is (some? fus-2)) (is (= "Order Date" (:display_name fus-2))) (is (= :type/CreationTimestamp (:semantic_type fus-2))))))) - (testing "matches joined columns using :lib/desired-column-alias instead of :name" (mt/with-temp [:model/Table {table-id :id} {:name "transform_joined_output" :db_id (mt/id) @@ -363,13 +340,10 @@ :description "The product identifier" :base_type "type/Integer"}])} :where [:= :id card-id]}) - (#'replacement.runner/copy-model-metadata-overrides! card-id table-id) - (let [field (t2/select-one :model/Field :id field-id)] (is (= "Product ID" (:display_name field))) (is (= "The product identifier" (:description field)))) - (let [fus (t2/select-one :model/FieldUserSettings :field_id field-id)] (is (some? fus)) (is (= "Product ID" (:display_name fus))) diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/card_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/card_test.clj index 75f06238c1e6..a9adc57a0243 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/card_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/card_test.clj @@ -109,7 +109,6 @@ :type :query, :query {:source-table (mt/id :venues) :limit 1}}}] - (perms/add-user-to-group! user-id group) (let [cases [[:unrestricted :query-builder-and-native true] [:unrestricted :query-builder true] @@ -157,7 +156,6 @@ (mt/user-http-request :rasta :post 403 "card" (assoc (api.card-test/card-with-name-and-query (mt/random-name) query) :collection_id (u/the-id collection)))) - (mt/with-temp [:model/Card card {:dataset_query (mt/mbql-query products)}] (let [query (mt/mbql-query orders {:limit 5 @@ -187,7 +185,6 @@ :values_source_config {:card_id source-card-id :value_field (mt/$ids $categories.name)}}] :table_id (mt/id :venues)}] - (testing "when getting values" (let [get-values (fn [user] (mt/user-http-request user :get 200 (api.card-test/param-values-url card-id "abc")))] @@ -196,7 +193,6 @@ (is (=? {:values [["African"] ["American"] ["Artisan"]] :has_more_values false} (get-values :rasta))))) - (testing "when searching values" ;; return BBQ if not sandboxed (let [search (fn [user] @@ -204,7 +200,6 @@ (is (=? {:values [["BBQ"]] :has_more_values false} (search :crowberto))) - (is (=? {:values [] :has_more_values false} (search :rasta))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/dashboard_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/dashboard_test.clj index 81e8b8f7d0a7..70235551df23 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/dashboard_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/dashboard_test.clj @@ -81,19 +81,16 @@ (testing "when getting values" (let [get-values (fn [user] (mt/user-http-request user :get 200 (api.dashboard-test/chain-filter-values-url dashboard-id "abc")))] - (is (> (-> (get-values :crowberto) :values count) 3)) (is (= {:values [["African"] ["American"] ["Artisan"]] :has_more_values false} (get-values :rasta))))) - (testing "when search values" (let [search (fn [user] (mt/user-http-request user :get 200 (api.dashboard-test/chain-filter-search-url dashboard-id "abc" "bbq")))] (is (= {:values [["BBQ"]] :has_more_values false} (search :crowberto))) - (is (= {:values [] :has_more_values false} (search :rasta))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/dataset_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/dataset_test.clj index dfa0562add94..102cf01889d4 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/dataset_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/dataset_test.clj @@ -12,7 +12,6 @@ [:model/Card {source-card-id :id} {:database_id (mt/id) :table_id (mt/id :categories) :dataset_query (mt/mbql-query categories)}] - (testing "when getting values" (let [get-values (fn [user] (mt/user-http-request user :post 200 "/dataset/parameter/values" @@ -22,13 +21,11 @@ :values_source_type "card" :values_source_config {:card_id source-card-id :value_field (mt/$ids $categories.name)}}}))] - ;; returns much more if not sandboxed (is (> (-> (get-values :crowberto) :values count) 3)) (is (=? {:values [["African"] ["American"] ["Artisan"]] :has_more_values false} (get-values :rasta))))) - (testing "when searching values" (let [search (fn [user] (mt/user-http-request user :post 200 "/dataset/parameter/search/BBQ" @@ -38,16 +35,13 @@ :values_source_type "card" :values_source_config {:card_id source-card-id :value_field (mt/$ids $categories.name)}}}))] - ;; returns `BBQ` if not sandboxed (is (=? {:values [["BBQ"]] :has_more_values false} (search :crowberto))) - (is (=? {:values [] :has_more_values false} (search :rasta))))))) - (testing "values_source_type=nil (values from fields)" (testing "when getting values" (let [get-values (fn [user] @@ -57,13 +51,11 @@ :name "CATEGORY" :values_source_type nil} :field_ids [(mt/id :categories :name)]}))] - ;; returns much more if not sandboxed (is (> (-> (get-values :crowberto) :values count) 3)) (is (=? {:values [["Artisan"] ["African"] ["American"]] :has_more_values false} (get-values :rasta))))) - (testing "when searching values" (let [search (fn [user] (mt/user-http-request user :post 200 "/dataset/parameter/search/BBQ" @@ -72,10 +64,8 @@ :name "CATEGORY" :values_source_type nil} :field_ids [(mt/id :categories :name)]}))] - ;; returns `BBQ` if not sandboxed (is (=? {:values [["BBQ"]]} (search :crowberto))) - (is (=? {:values []} (search :rasta))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/download_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/download_test.clj index acfa968e3e96..9470ceaa7f23 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/download_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/download_test.clj @@ -15,12 +15,10 @@ :attributes {"state" "CA"}}) (mt/with-temp [:model/Card card {:dataset_query (mt/mbql-query people)}] (data-perms/set-table-permission! &group (mt/id :people) :perms/download-results :one-million-rows) - ;; Sanity check: admin can download full table (let [res (-> (mt/user-http-request :crowberto :post 200 (format "card/%d/query/csv" (u/the-id card))) csv/read-csv)] (is (= 2501 (count res)))) - ;; Sandboxed user only downloads a subset (users in CA) (let [res (-> (mt/user-http-request :rasta :post 200 (format "card/%d/query/csv" (u/the-id card))) csv/read-csv)] diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/field_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/field_test.clj index 55f13ee455f1..ad10c9b7c3b6 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/field_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/field_test.clj @@ -55,7 +55,6 @@ ["La Tortilla"]] :has_more_values false} (fetch-values :rasta :name))) - (testing (str "Now in this case recall that the `restricted-column-query` GTAP we're using does *not* include " "`venues.price` in the results. (Toucan isn't allowed to know the number of dollar signs!) So " "make sure if we try to fetch the field values instead of seeing `[[1] [2] [3] [4]]` we get no " @@ -64,7 +63,6 @@ :values [] :has_more_values false} (fetch-values :rasta :price)))) - (testing "Reset field values; if another User fetches them first, do I still see sandboxed values? (metabase/metaboat#128)" (field-values/clear-field-values-for-field! (mt/id :venues :name)) ;; fetch Field values with an admin @@ -154,7 +152,6 @@ (is (= 1 (t2/count :model/FieldValues :field_id (:id field) :type :advanced))))) - (testing "Do different users has different sandbox FieldValues" (let [password (mt/random-name)] (mt/with-temp [:model/User another-user {:password password}] @@ -167,7 +164,6 @@ (is (= 2 (t2/count :model/FieldValues :field_id (:id field) :type :advanced))))))) - (testing "Do we invalidate the cache when full FieldValues change" (try (let [;; Updating FieldValues which should invalidate the cache @@ -184,7 +180,6 @@ (finally ;; Put everything back as it was (field-values/get-or-create-full-field-values! field)))) - (testing "When a sandbox fieldvalues expired, do we delete it then create a new one?" (#'field-values/clear-advanced-field-values-for-field! field) ;; make sure we have a cache diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/gtap_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/gtap_test.clj index 11f82ca00597..349c1e585d33 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/gtap_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/gtap_test.clj @@ -14,7 +14,6 @@ (mt/with-premium-features #{:sandboxes} (is (= (get api.response/response-unauthentic :body) (client/client :get 401 "mt/gtap"))) - (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "mt/gtap")))))) @@ -73,7 +72,6 @@ (filter #(#{gtap-id-1 gtap-id-2} (:id %)) (mt/user-http-request :crowberto :get 200 "mt/gtap/"))))) - (testing "Test that we can fetch the GTAP for a specific table and group" (is (partial= {:id gtap-id-1 :table_id table-id-1 :group_id group-id-1} @@ -95,7 +93,6 @@ (mt/boolean-ids-and-timestamps post-results))) (is (= post-results (mt/user-http-request :crowberto :get 200 (format "mt/gtap/%s" (:id post-results))))))))) - (testing "Test that we can create a new GTAP without a card" (with-gtap-cleanup! (let [post-results (gtap-post {:table_id table-id @@ -106,7 +103,6 @@ (mt/boolean-ids-and-timestamps post-results))) (is (= post-results (mt/user-http-request :crowberto :get 200 (format "mt/gtap/%s" (:id post-results)))))))) - (testing "Meaningful errors should be returned if you create an invalid GTAP" (mt/with-temp [:model/Field _ {:name "My field" :table_id table-id :base_type :type/Integer} :model/Card {card-id :id} {:dataset_query (mt/mbql-query venues @@ -133,7 +129,6 @@ {:table_id table-id :group_id group-id :card_id card-id})))) - (testing "A sandbox without a card-id passes validation, because the validation is not applicable in this case" (with-gtap-cleanup! (mt/user-http-request :crowberto :post 204 "mt/gtap/validate" @@ -141,7 +136,6 @@ :group_id group-id :card_id nil :attribute_remappings {"foo" 1}}))) - (testing "An invalid sandbox results in a 400 error being returned" (mt/with-temp [:model/Field _ {:name "My field", :table_id table-id, :base_type :type/Integer} :model/Card {card-id :id} {:dataset_query (mt/mbql-query venues @@ -200,7 +194,6 @@ (mt/boolean-ids-and-timestamps (mt/user-http-request :crowberto :put 200 (format "mt/gtap/%s" gtap-id) {:attribute_remappings {:bar 2}})))))) - (testing "Test that we can add a card_id via PUT" (mt/with-temp [:model/Sandbox {gtap-id :id} {:table_id table-id :group_id group-id @@ -210,7 +203,6 @@ (mt/boolean-ids-and-timestamps (mt/user-http-request :crowberto :put 200 (format "mt/gtap/%s" gtap-id) {:card_id card-id})))))) - (testing "Test that we can remove a card_id via PUT" (mt/with-temp [:model/Sandbox {gtap-id :id} {:table_id table-id :group_id group-id @@ -220,7 +212,6 @@ (mt/boolean-ids-and-timestamps (mt/user-http-request :crowberto :put 200 (format "mt/gtap/%s" gtap-id) {:card_id nil})))))) - (testing "Test that we can remove a card_id and change attribute remappings via PUT" (mt/with-temp [:model/Sandbox {gtap-id :id} {:table_id table-id :group_id group-id @@ -256,7 +247,6 @@ :attribute_remappings {:foo 1}}] (:sandboxes result))) (is (t2/exists? :model/Sandbox :table_id table-id-1 :group_id group-id)))) - (testing "Test that we can update a sandbox using the permission graph API" (let [sandbox-id (t2/select-one-fn :id :model/Sandbox :table_id table-id-1 @@ -273,7 +263,6 @@ (t2/select-one :model/Sandbox :table_id table-id-1 :group_id group-id))))) - (testing "Test that we can create and update multiple sandboxes at once using the permission graph API" (let [sandbox-id (t2/select-one-fn :id :model/Sandbox :table_id table-id-1 diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/table_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/table_test.clj index 78af229eae89..3c933c9039bb 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/table_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/table_test.clj @@ -30,7 +30,6 @@ included in the sandboxing question" (is (= #{"CATEGORY_ID" "ID" "NAME"} (field-names :rasta)))) - (testing "Users with full permissions should not be affected by this field filtering" (is (= all-columns (field-names :crowberto))))))) @@ -66,12 +65,10 @@ (if metadata (t2/update! :model/Card :id (u/the-id card) {:result_metadata metadata}) (card.metadata/save-metadata-async! metadata-future card))) - (testing "Users with restricted access to the columns of a table via a native query sandbox should only see columns included in the sandboxing question" (is (= #{"CATEGORY_ID" "ID" "NAME"} (field-names :rasta)))) - (testing "Users with full permissions should not be affected by this field filtering" (is (= all-columns (field-names :crowberto))))))) @@ -112,7 +109,6 @@ (is (= #{"VENUES.CATEGORY_ID" "VENUES.ID" "VENUES.NAME"} (->> (table/batch-fetch-table-query-metadatas [(mt/id :venues) (mt/id :checkins)] nil) upper-case-field-names))))) - (testing "Users with full permissions should not be affected by this field filtering" (mt/with-current-user (mt/user->id :crowberto) (is (= #{"CHECKINS.DATE" "CHECKINS.ID" "CHECKINS.USER_ID" "CHECKINS.VENUE_ID" diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/user_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/user_test.clj index 6d18bf215d99..0f63446b5344 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/user_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/user_test.clj @@ -47,12 +47,10 @@ (mt/with-premium-features #{} (testing "requires sandbox enabled" (mt/assert-has-premium-feature-error "Sandboxes" (mt/user-http-request :crowberto :get 402 "mt/user/attributes")))) - (mt/with-premium-features #{:sandboxes} (testing "requires admin" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "mt/user/attributes")))) - (testing "returns set of user attributes" (mt/with-temp [:model/User _ {:login_attributes {:foo "bar"}} @@ -85,7 +83,6 @@ :attributes {"tenant-key-3" "value3" "tenant-key-1" "different-value"}} :model/User _ {:login_attributes {:user-key "user-value"}}] - (let [attributes (set (mt/user-http-request :crowberto :get 200 "mt/user/attributes"))] (is (set/subset? #{"tenant-key-1" "tenant-key-2" "tenant-key-3" "user-key"} attributes)))))))) @@ -94,16 +91,13 @@ (mt/with-premium-features #{} (testing "requires sandbox enabled" (mt/assert-has-premium-feature-error "Sandboxes" (mt/user-http-request :crowberto :put 402 (format "mt/user/%d/attributes" (mt/user->id :crowberto)) {})))) - (mt/with-premium-features #{:sandboxes} (testing "requires admin" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (format "mt/user/%d/attributes" (mt/user->id :rasta)) {})))) - (testing "404 if user does not exist" (is (= "Not found." (mt/user-http-request :crowberto :put 404 (format "mt/user/%d/attributes" Integer/MAX_VALUE) {})))) - (testing "Admin can update user attributes" (mt/with-temp [:model/User {id :id} {}] @@ -126,13 +120,11 @@ (is (contains? (set response) "department")) (is (contains? (set response) "role")) (is (contains? (set response) "team"))) - (testing "includes keys from jwt_attributes" (is (contains? (set response) "session_id")) (is (contains? (set response) "scope")) (is (contains? (set response) "auth_level")) (is (contains? (set response) "region"))) - (testing "does not include duplicate keys" (let [response-counts (frequencies response)] (is (every? #(= 1 %) (vals response-counts)))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/api/util_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/api/util_test.clj index 9769def43b54..944a9a867fcc 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/api/util_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/api/util_test.clj @@ -14,15 +14,12 @@ (met/with-gtaps-for-user! (u/the-id user) {:gtaps {:venues {}}} ;; retrieve the cache now (and realize its values) so it doesn't get included in call count (doall @data-perms/*sandboxes-for-user*) - ;; make the cache wrong (t2/delete! :model/Sandbox :group_id (:id &group)) - ;; subsequent calls should still use the cache, and not hit the DB at all (t2/with-call-count [call-count] (is (sandbox.api.util/sandboxed-user?)) (is (zero? (call-count))) - (is (= 1 (count (sandbox.api.util/enforced-sandboxes-for-tables [(mt/id :venues)])))) (is (zero? (call-count)))))))) @@ -31,13 +28,11 @@ (mt/with-temp [:model/User user {}] (met/with-gtaps-for-user! (u/the-id user) {:gtaps {:venues {}}} (is (sandbox.api.util/sandboxed-user?))))) - (testing "If a user is in another group with view data access, the sandbox should not be enforced" (mt/with-temp [:model/User user {}] (met/with-gtaps-for-user! (u/the-id user) {:gtaps {:venues {}}} (mt/with-full-data-perms-for-all-users! (is (not (sandbox.api.util/sandboxed-user?))))))) - (testing "If a user is in another group with another sandbox defined on the table, the user should be considered sandboxed" ;; This (conflicting sandboxes) is an invalid state for the QP but `enforce-sandbox?` should return true in order ;; to fail closed @@ -45,7 +40,6 @@ (met/with-gtaps-for-user! (u/the-id user) {:gtaps {:venues {}}} (met/with-gtaps-for-user! (u/the-id user) {:gtaps {:venues {}}} (is (sandbox.api.util/sandboxed-user?)))))) - (testing "If a user is in another group with an impersonation policy defined on the table, the user should be considered sandboxed" ;; Similar to above, this is also an unsupported configuration for querying, but we want to treat this user as ;; sandboxed @@ -58,7 +52,6 @@ :group_id group-id :attribute "test-attribute"}] (is (sandbox.api.util/sandboxed-user?)))))) - (testing "If a user is in two groups with conflicting sandboxes, *and* a third group that grants full access to the table, neither sandbox is enforced" (mt/with-temp [:model/User user {}] @@ -76,6 +69,5 @@ (testing "Admins should not be classified as segmented users -- enterprise #147" (testing "Non-admin" (is (has-segmented-perms-when-segmented-db-exists?! :rasta))) - (testing "Admin" (is (not (has-segmented-perms-when-segmented-db-exists?! :crowberto)))))) diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/models/params/chain_filter_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/models/params/chain_filter_test.clj index 063719aeaeea..69997316a99c 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/models/params/chain_filter_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/models/params/chain_filter_test.clj @@ -18,12 +18,10 @@ :has_more_values false} (mt/$ids (chain-filter/chain-filter %categories.name nil)))) (is (= 1 (t2/count :model/FieldValues :field_id (mt/id :categories :name) :type :advanced)))) - (testing "search" (is (= {:values [["African"] ["American"]] :has_more_values false} (mt/$ids (chain-filter/chain-filter-search %categories.name nil "a"))))) - (testing "When chain-filter with constraints" (testing "creates a linked-filter FieldValues if not sandboxed" (binding [data-perms/*sandboxes-for-user* (delay nil)] @@ -32,7 +30,6 @@ (mt/$ids (chain-filter/chain-filter %categories.name [{:field-id %categories.id :op := :value 3}]))))) (is (= 2 (t2/count :model/FieldValues :field_id (mt/id :categories :name) :type :advanced)))) - (testing "creates another linked-filter FieldValues if sandboxed" (is (= {:values [] :has_more_values false} diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/models/params/field_values_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/models/params/field_values_test.clj index 0b9fbf5bdd75..65b2a4534fbc 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/models/params/field_values_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/models/params/field_values_test.clj @@ -32,12 +32,10 @@ (is (= [4 5] (:values fv))) (is (= ["id_4" "id_5"] (:human_readable_values fv))) (is (some? (:hash_key fv))) - (testing "call second time shouldn't create a new FieldValues" (params.field-values/get-or-create-field-values! (t2/select-one :model/Field :id (mt/id :categories :id))) (is (= 1 (t2/count :model/FieldValues :field_id categories-id :type :advanced)))) - (testing "after changing the question, should create new FieldValues" (let [new-query (mt/mbql-query categories {:filter [:and [:> $id 1] [:< $id 4]]})] @@ -65,7 +63,6 @@ field (t2/select-one :model/Field (mt/id :categories :name))] (mt/with-temp [:model/User {user-id-1 :id} {} :model/User {user-id-2 :id} {}] - (testing "2 users with the same attribute" (testing "should have the same hash for the same field" (is (= (hash-input-for-user-id user-id-1 {"State" "CA"} field) @@ -74,15 +71,12 @@ (is (= (hash-input-for-user-id user-id-1 {"State" "CA" "City" "San Jose"} field) (hash-input-for-user-id user-id-2 {"State" "CA"} field))))) - (testing "2 users with the same attribute should have the different hash for different " (is (= (hash-input-for-user-id user-id-1 {"State" "CA"} field) (hash-input-for-user-id user-id-2 {"State" "CA"} field)))) - (testing "same users but the login_attributes change should have different hash" (is (not= (hash-input-for-user-id user-id-1 {"State" "CA"} field) (hash-input-for-user-id user-id-1 {"State" "NY"} field)))) - (testing "2 users with different login_attributes should have different hash" (is (not= (hash-input-for-user-id user-id-1 {"State" "CA"} field) (hash-input-for-user-id user-id-2 {"State" "NY"} field))) @@ -113,15 +107,12 @@ :group_id group-id :table_id (mt/id :categories) :attribute_remappings {"State" [:dimension [:field (mt/id :categories :name) nil]]}}] - (testing "with same attributes, the hash should be the same field" (is (= (hash-input-for-user-id-with-attributes user-id-1 {"State" "CA"} field) (hash-input-for-user-id-with-attributes user-id-2 {"State" "CA"} field)))) - (testing "with different attributes, the hash should be the different" (is (not= (hash-input-for-user-id-with-attributes user-id-1 {"State" "CA"} field) (hash-input-for-user-id-with-attributes user-id-2 {"State" "NY"} field)))))) - (testing "gtap with native question" (mt/with-temp [:model/Card {card-id :id} {:query_type :native @@ -147,7 +138,6 @@ (testing "same users but if the login_attributes change, they should have different hash (#24966)" (is (not= (hash-input-for-user-id-with-attributes user-id {"State" "CA"} field) (hash-input-for-user-id-with-attributes user-id {"State" "NY"} field)))))) - (testing "2 users in different groups but gtaps use the same card" (mt/with-temp [:model/Card {card-id :id} {} @@ -173,11 +163,9 @@ (testing "with the same attributes, the hash should be the same" (is (= (hash-input-for-user-id-with-attributes user-id-1 {"State" "CA"} field) (hash-input-for-user-id-with-attributes user-id-2 {"State" "CA"} field)))) - (testing "with different attributes, the hash should be the different" (is (not= (hash-input-for-user-id-with-attributes user-id-1 {"State" "CA"} field) (hash-input-for-user-id-with-attributes user-id-2 {"State" "NY"} field)))))) - (testing "2 users in different groups and gtaps use 2 different cards" (mt/with-temp [:model/Card {card-id-1 :id} {} diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/models/sandbox_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/models/sandbox_test.clj index 0ca9f743927e..79628d6a117f 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/models/sandbox_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/models/sandbox_test.clj @@ -114,7 +114,6 @@ {"PUBLIC" {(mt/id :venues) :sandboxed}}}}} (sandboxes/add-sandboxes-to-permissions-graph {}))))) - (testing "When perms are set at the DB level, incorporating a sandbox breaks them out to table-level" (mt/with-temp [:model/Sandbox _gtap {:table_id (mt/id :venues) :group_id (u/the-id (perms-group/all-users))}] @@ -127,7 +126,6 @@ {(u/the-id (perms-group/all-users)) {(mt/id) {:view-data :unrestricted}}}))))) - (testing "When perms are set at the schema level, incorporating a sandbox breaks them out to table-level" (mt/with-temp [:model/Sandbox _gtap {:table_id (mt/id :venues) :group_id (u/the-id (perms-group/all-users))}] diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/notification_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/notification_test.clj index bff9b784daac..52e702a8af52 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/notification_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/notification_test.clj @@ -21,7 +21,6 @@ :result :data :rows))] - (is (= [[100]] (send-alert-by-user! :crowberto))) (is (= [[10]] diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/pulse_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/pulse_test.clj index da861817dca9..72ba4e1839d9 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/pulse_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/pulse_test.clj @@ -106,11 +106,9 @@ (testing "ad-hoc query" (is (= 22 (count (mt/rows (qp/process-query query)))))) - (testing "in a Saved Question" (is (= 22 (count (mt/rows (mt/user-http-request :rasta :post 202 (format "card/%d/query" (u/the-id card))))))))) - (testing "Pulse should be sandboxed" (is (= 22 (count (mt/rows (alert-results! query)))))))))))) @@ -187,17 +185,14 @@ (is (= (sort [(mt/user->id :rasta) (mt/user->id :crowberto)]) (-> (mt/user-http-request :rasta :get 200 "pulse/") recipient-ids))) - (is (= (sort [(mt/user->id :rasta) (mt/user->id :crowberto)]) (-> (mt/user-http-request :rasta :get 200 (format "pulse/%d" pulse-id)) vector recipient-ids)))) - (with-redefs [perms-util/sandboxed-or-impersonated-user? (constantly true)] (is (= [(mt/user->id :rasta)] (-> (mt/user-http-request :rasta :get 200 "pulse/") recipient-ids))) - (is (= [(mt/user->id :rasta)] (-> (mt/user-http-request :rasta :get 200 (format "pulse/%d" pulse-id)) vector @@ -213,22 +208,18 @@ :details {:emails "asdf@metabase.com"}} :model/PulseChannelRecipient _ {:pulse_channel_id pc-id :user_id (mt/user->id :crowberto)} :model/PulseChannelRecipient _ {:pulse_channel_id pc-id :user_id (mt/user->id :rasta)}] - (mt/with-test-user :rasta (with-redefs [perms-util/sandboxed-or-impersonated-user? (constantly true)] ;; Rasta, a sandboxed user, updates the pulse, but does not include Crowberto in the recipients list (mt/user-http-request :rasta :put 200 (format "pulse/%d" pulse-id) {:channels [(assoc pc :recipients [{:id (mt/user->id :rasta)}])]})) - ;; Check that both Rasta and Crowberto are still recipients (is (= (sort [(mt/user->id :rasta) (mt/user->id :crowberto)]) (->> (#'api.pulse/email-channel (models.pulse/retrieve-alert pulse-id)) :recipients (map :id) sort))) - (with-redefs [perms-util/sandboxed-or-impersonated-user? (constantly false)] ;; Rasta, a non-sandboxed user, updates the pulse, but does not include Crowberto in the recipients list (mt/user-http-request :rasta :put 200 (format "pulse/%d" pulse-id) {:channels [(assoc pc :recipients [{:id (mt/user->id :rasta)}])]}) - ;; Crowberto should now be removed as a recipient (is (= [(mt/user->id :rasta)] (->> (#'api.pulse/email-channel (models.pulse/retrieve-alert pulse-id)) :recipients (map :id) sort)))))))) diff --git a/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/sandboxing_test.clj b/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/sandboxing_test.clj index f0dc338fa059..631668f922a5 100644 --- a/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/sandboxing_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sandbox/query_processor/middleware/sandboxing_test.clj @@ -870,7 +870,6 @@ (let [[_ chan] (a/alts!! [save-chan (a/timeout 5000)])] (is (= save-chan chan)))) - (testing "Run it again, should be cached" (let [result (run-query)] (is (true? @@ -1246,7 +1245,6 @@ (is (= "persisted" (:state persisted-info)) "Model failed to persist") (is (string? (:table_name persisted-info))) - (let [query (mt/mbql-query nil ;; just generate a select count(*) from card__ {:aggregation [:count] @@ -1406,14 +1404,12 @@ (mt/with-test-user :rasta (is (= [[10]] (run-venues-count-query)))))) - (testing "with jwt_attributes" (tu/with-temp-vals-in-db :model/User (mt/user->id :rasta) {:jwt_attributes {"cat" 50} :login_attributes {}} (mt/with-test-user :rasta (is (= [[10]] (run-venues-count-query)))))) - (testing "login_attributes override jwt_attributes when both present" (tu/with-temp-vals-in-db :model/User (mt/user->id :rasta) {:jwt_attributes {"cat" 40} :login_attributes {"cat" 50}} @@ -1432,7 +1428,6 @@ (mt/with-test-user :rasta (is (= #{[nil 45] [1 10]} (set (run-checkins-count-broken-out-by-price-query))))))) - (testing "login_attributes take precedence for conflicting keys" (tu/with-temp-vals-in-db :model/User (mt/user->id :rasta) {:jwt_attributes {"user" 3, "price" 2} :login_attributes {"user" 5, "price" 1}} @@ -1450,7 +1445,6 @@ (mt/with-test-user :rasta (is (= [[10]] (run-venues-count-query)))))) - (testing "Numeric string coercion works with jwt_attributes" (tu/with-temp-vals-in-db :model/User (mt/user->id :rasta) {:jwt_attributes {"cat" "50"} :login_attributes {}} @@ -1470,7 +1464,6 @@ clojure.lang.ExceptionInfo #"Query requires user attribute `cat`" (mt/run-mbql-query venues {:aggregation [[:count]]})))))) - (testing "Nil attribute in jwt_attributes throws error" (tu/with-temp-vals-in-db :model/User (mt/user->id :rasta) {:jwt_attributes {"cat" nil} :login_attributes {}} @@ -1498,11 +1491,9 @@ (:cached (:cache/details result)))) (is (= [[10]] (mt/rows result))))))) - (testing "Cache entry saved" (let [[_ chan] (a/alts!! [save-chan (a/timeout 5000)])] (is (= save-chan chan)))) - (testing "Different jwt_attributes don't use cached result" (tu/with-temp-vals-in-db :model/User (mt/user->id :rasta) {:jwt_attributes {"cat" 40} :login_attributes {}} @@ -1594,7 +1585,6 @@ (mt/with-test-user :rasta (is (= [[10]] (run-venues-count-query)))))) - (testing "Numeric string coercion with attributes-only GTAP" (tu/with-temp-vals-in-db :model/User (mt/user->id :rasta) {:jwt_attributes {"cat" "50"} :login_attributes {}} diff --git a/enterprise/backend/test/metabase_enterprise/scim/api_test.clj b/enterprise/backend/test/metabase_enterprise/scim/api_test.clj index c8ce657716a4..9b734e822524 100644 --- a/enterprise/backend/test/metabase_enterprise/scim/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/scim/api_test.clj @@ -22,7 +22,6 @@ (t2/delete! :model/ApiKey :scope :scim) (let [key1 (#'scim/refresh-scim-api-key! (mt/user->id :crowberto))] (is (=? (scim-api-key-shape :crowberto) key1)) - (testing "The same function will refresh an existing SCIM API key" (let [key2 (#'scim/refresh-scim-api-key! (mt/user->id :crowberto))] (is (=? (scim-api-key-shape :crowberto) key2)) @@ -37,10 +36,8 @@ fetched-key (mt/user-http-request :crowberto :get 200 "ee/scim/api_key")] (is (nil? (:unmasked_key fetched-key))) (is (= (:key actual-key) (:key fetched-key))))) - (testing "A non-admin cannot fetch the SCIM API key" (mt/user-http-request :rasta :get 403 "ee/scim/api_key")) - (testing "A 404 is returned if the key has not yet been created" (t2/delete! :model/ApiKey :scope :scim) (mt/user-http-request :crowberto :get 404 "ee/scim/api_key"))))) diff --git a/enterprise/backend/test/metabase_enterprise/scim/v2/api_test.clj b/enterprise/backend/test/metabase_enterprise/scim/v2/api_test.clj index d26029e494af..ea22af1e8242 100644 --- a/enterprise/backend/test/metabase_enterprise/scim/v2/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/scim/v2/api_test.clj @@ -53,17 +53,13 @@ (with-scim-setup! (testing "SCIM endpoints require a valid SCIM API key passed in the authorization header" (scim-client :get 200 "ee/scim/v2/Users")) - (testing "SCIM endpoints cannot be used if SCIM is not enabled" (mt/with-temporary-setting-values [scim-enabled false] (scim-client :get 401 "ee/scim/v2/Users"))) - (testing "The SCIM API key cannot be used for non-SCIM endpoints" (scim-client :get 401 "user")) - (testing "SCIM endpoints do not allow normal auth" (mt/user-http-request :crowberto :get 401 "ee/scim/v2/Users")) - (testing "A SCIM API key cannot be passed via the x-api-key header" (client/client :get 401 "ee/scim/v2/Users" {:request-options {:headers {"x-api-key" *scim-api-key*}}})))) @@ -75,13 +71,11 @@ (scim-client :get 200 "ee/scim/v2/Users") (is (== 1 (mt/metric-value system :metabase-scim/response-ok))) (is (== 0 (mt/metric-value system :metabase-scim/response-error)))) - (testing "Bad request (400)" (scim-client :get 400 (format "ee/scim/v2/Users?filter=%s" (codec/url-encode "id ne \"newuser@metabase.com\""))) (is (== 1 (mt/metric-value system :metabase-scim/response-ok))) (is (== 1 (mt/metric-value system :metabase-scim/response-error)))) - (testing "Unexpected server error (500)" (with-redefs [scim-api/scim-response #(throw (Exception.))] (scim-client :get 500 "ee/scim/v2/Users") @@ -109,7 +103,6 @@ :display "Test Group"}] :meta {:resourceType "User"}} response))))) - (testing "404 is returned when fetching a non-existent user" (scim-client :get 404 (format "ee/scim/v2/Users/%s" (random-uuid)))))) @@ -118,7 +111,6 @@ (testing "Fetch users with default pagination" (let [response (scim-client :get 200 "ee/scim/v2/Users")] (is (malli= scim-api/SCIMUserList response)))) - (testing "Fetch users with custom pagination" (let [response (scim-client :get 200 (format "ee/scim/v2/Users?startIndex=%d&count=%d" 1 2))] (is (= ["urn:ietf:params:scim:api:messages:2.0:ListResponse"] (get response :schemas))) @@ -126,28 +118,24 @@ (is (= 1 (get response :startIndex))) (is (= 2 (get response :itemsPerPage))) (is (= 2 (count (get response :Resources)))))) - (testing "Fetch user by email" (let [response (scim-client :get 200 (format "ee/scim/v2/Users?filter=%s" (codec/url-encode "userName eq \"rasta@metabase.com\"")))] (is (malli= scim-api/SCIMUserList response)) (is (= 1 (get response :totalResults))) (is (= 1 (count (get response :Resources)))))) - (testing "Fetch deactivated user by email" (let [response (scim-client :get 200 (format "ee/scim/v2/Users?filter=%s" (codec/url-encode "userName eq \"trashbird@metabase.com\"")))] (is (malli= scim-api/SCIMUserList response)) (is (= 1 (get response :totalResults))) (is (= 1 (count (get response :Resources)))))) - (testing "Fetch non-existent user by email" (let [response (scim-client :get 200 (format "ee/scim/v2/Users?filter=%s" (codec/url-encode "userName eq \"newuser@metabase.com\"")))] (is (malli= scim-api/SCIMUserList response)) (is (= 0 (get response :totalResults))) (is (= 0 (count (get response :Resources)))))) - (testing "Error if unsupported filter operation is provided" (scim-client :get 400 (format "ee/scim/v2/Users?filter=%s" (codec/url-encode "id ne \"newuser@metabase.com\"")))))) @@ -171,7 +159,6 @@ (t2/select-one [:model/User :email :first_name :last_name :is_active :sso_source] :entity_id (:id response))))) (finally (t2/delete! :model/User :email (:email user)))))) - (testing "Error when creating a user with an existing email" (let [existing-user {:schemas ["urn:ietf:params:scim:schemas:core:2.0:User"] :userName "rasta@metabase.com" @@ -202,7 +189,6 @@ (is (malli= scim-api/SCIMUser response)) (is (= "UpdatedTest" (get-in response [:name :givenName]))) (is (= "UpdatedUser" (get-in response [:name :familyName])))) - (testing "Error when trying to update the email of an existing user" (let [update-user {:schemas ["urn:ietf:params:scim:schemas:core:2.0:User"] :id entity-id @@ -213,7 +199,6 @@ response (scim-client :put 400 (format "ee/scim/v2/Users/%s" entity-id) update-user)] (is (= ["urn:ietf:params:scim:api:messages:2.0:Error"] (get response :schemas))) (is (= "You may not update the email of an existing user." (get response :detail))))) - (testing "Error when trying to update a non-existent user" (let [update-user {:schemas ["urn:ietf:params:scim:schemas:core:2.0:User"] :id (str (random-uuid)) @@ -241,7 +226,6 @@ response (scim-client :patch 200 (format "ee/scim/v2/Users/%s" entity-id) patch-body)] (is (malli= scim-api/SCIMUser response)) (is (= false (:active response))))) - (testing "Reactivate an existing user" (let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] :Operations [{:op "Replace" @@ -251,7 +235,6 @@ response (scim-client :patch 200 (format "ee/scim/v2/Users/%s" entity-id) patch-body)] (is (malli= scim-api/SCIMUser response)) (is (true? (:active response))))) - (testing "Update family name of an existing user" (let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] :Operations [{:op "Replace" @@ -260,7 +243,6 @@ response (scim-client :patch 200 (format "ee/scim/v2/Users/%s" entity-id) patch-body)] (is (malli= scim-api/SCIMUser response)) (is (= "UpdatedUser" (get-in response [:name :familyName]))))) - (testing "Update multiple attributes of an existing user" (let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] :Operations [{:op "Replace" @@ -278,7 +260,6 @@ (is (= "UpdatedFirstName" (get-in response [:name :givenName]))) (is (= "UpdatedLastName" (get-in response [:name :familyName]))) (is (true? (response :active))))) - (testing "Error when using unsupported path" (let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] :Operations [{:op "replace" @@ -287,7 +268,6 @@ response (scim-client :patch 400 (format "ee/scim/v2/Users/%s" entity-id) patch-body)] (is (= ["urn:ietf:params:scim:api:messages:2.0:Error"] (get response :schemas))) (is (= "Unsupported path: name.displayName" (get response :detail))))) - (testing "Error when trying to update a non-existent user" (let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] :Operations [{:op "Replace" @@ -312,7 +292,6 @@ response (scim-client :patch 200 (format "ee/scim/v2/Users/%s" entity-id) patch-body)] (is (malli= scim-api/SCIMUser response)) (is (= false (:active response))))) - (testing "Reactivate an existing user" (let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] :Operations [{:op "Replace" @@ -320,7 +299,6 @@ response (scim-client :patch 200 (format "ee/scim/v2/Users/%s" entity-id) patch-body)] (is (malli= scim-api/SCIMUser response)) (is (true? (:active response))))) - (testing "Update family name of an existing user" (let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] :Operations [{:op "Replace" @@ -328,7 +306,6 @@ response (scim-client :patch 200 (format "ee/scim/v2/Users/%s" entity-id) patch-body)] (is (malli= scim-api/SCIMUser response)) (is (= "UpdatedUser" (get-in response [:name :familyName]))))) - (testing "Update multiple attributes of an existing user" (let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] :Operations [{:op "Replace" @@ -343,7 +320,6 @@ (is (= "UpdatedFirstName" (get-in response [:name :givenName]))) (is (= "UpdatedLastName" (get-in response [:name :familyName]))) (is (true? (response :active))))) - (testing "Error when using unsupported path" (let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] :Operations [{:op "replace" @@ -351,7 +327,6 @@ response (scim-client :patch 400 (format "ee/scim/v2/Users/%s" entity-id) patch-body)] (is (= ["urn:ietf:params:scim:api:messages:2.0:Error"] (get response :schemas))) (is (= "Unsupported path: name.displayName" (get response :detail))))) - (testing "Error when trying to update a non-existent user" (let [patch-body {:schemas ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] :Operations [{:op "Replace" @@ -359,14 +334,12 @@ response (scim-client :patch 404 (format "ee/scim/v2/Users/%s" (random-uuid)) patch-body)] (is (= ["urn:ietf:params:scim:api:messages:2.0:Error"] (get response :schemas))) (is (= "User not found" (get response :detail))))) - (deftest list-groups-test (with-scim-setup! (mt/with-temp [:model/PermissionsGroup _group1 {:name "Group 1"}] (testing "Fetch groups with default pagination" (let [response (scim-client :get 200 "ee/scim/v2/Groups")] (is (malli= scim-api/SCIMGroupList response)))) - (testing "Fetch groups with custom pagination" (let [response (scim-client :get 200 (format "ee/scim/v2/Groups?startIndex=%d&count=%d" 1 2))] (is (= ["urn:ietf:params:scim:api:messages:2.0:ListResponse"] (get response :schemas))) @@ -374,25 +347,21 @@ (is (= 1 (get response :startIndex))) (is (= 2 (get response :itemsPerPage))) (is (= 2 (count (get response :Resources)))))) - (testing "Fetch group by name" (let [response (scim-client :get 200 (format "ee/scim/v2/Groups?filter=%s" (codec/url-encode "displayName eq \"Group 1\"")))] (is (malli= scim-api/SCIMGroupList response)) (is (= 1 (get response :totalResults))) (is (= 1 (count (get response :Resources)))))) - (testing "Fetch non-existent group by name" (let [response (scim-client :get 200 (format "ee/scim/v2/Groups?filter=%s" (codec/url-encode "displayName eq \"Fake Group\"")))] (is (malli= scim-api/SCIMUserList response)) (is (= 0 (get response :totalResults))) (is (= 0 (count (get response :Resources)))))) - (testing "Error if unsupported filter operation is provided" (scim-client :get 400 (format "ee/scim/v2/Users?filter=%s" (codec/url-encode "displayName ne \"Group 1\""))))))))) - (deftest fetch-group-test (with-scim-setup! (testing "A single group can be fetched in the SCIM format by entity ID with its members" @@ -409,10 +378,8 @@ :display "rasta@metabase.com"}] :meta {:resourceType "Group"}} response))))) - (testing "404 is returned when fetching a non-existent group" (scim-client :get 404 (format "ee/scim/v2/Groups/%s" (random-uuid)))) - (testing "404 is returned when fetching the Admin or All Users group" (let [entity-ids (t2/select-fn-set :entity_id :model/PermissionsGroup {:where [:in :id #{(:id (perms-group/admin)) (:id (perms-group/all-users))}]})] @@ -462,10 +429,8 @@ (scim-client :delete 204 (format "ee/scim/v2/Groups/%s" entity-id)) (is (not (t2/exists? :model/PermissionsGroup :id (:id group)))) (is (not (t2/exists? :model/PermissionsGroupMembership :group_id (:id group))))))) - (testing "404 is returned when trying to delete a non-existent group" (scim-client :delete 404 (format "ee/scim/v2/Groups/%s" (random-uuid)))) - (testing "404 is returned when trying to delete the Admin or All Users group as they are not visible to SCIM" (let [entity-ids (t2/select-fn-set :entity_id :model/PermissionsGroup {:where [:in :id #{(:id (perms-group/admin)) (:id (perms-group/all-users))}]})] diff --git a/enterprise/backend/test/metabase_enterprise/search/scoring_test.clj b/enterprise/backend/test/metabase_enterprise/search/scoring_test.clj index 29cca6615ca6..3639976b8775 100644 --- a/enterprise/backend/test/metabase_enterprise/search/scoring_test.clj +++ b/enterprise/backend/test/metabase_enterprise/search/scoring_test.clj @@ -141,15 +141,12 @@ (is (= #{} (set/intersection #{"official collection score" "verified"} (score-result-names)))))) - (testing "includes official collection score if :official-collections is enabled" (mt/with-premium-features #{:official-collections} (is (set/subset? #{"official collection score"} (score-result-names))))) - (testing "includes verified if :content-verification is enabled" (mt/with-premium-features #{:content-verification} (is (set/subset? #{"verified"} (score-result-names))))) - (testing "includes both if has both features" (mt/with-premium-features #{:official-collections :content-verification} (is (set/subset? #{"official collection score" "verified"} (score-result-names))))))) diff --git a/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj b/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj index e6236536b8a0..9f18d56e3b73 100644 --- a/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj +++ b/enterprise/backend/test/metabase_enterprise/security_center/notification_test.clj @@ -274,7 +274,6 @@ (is (= 3 (count recipients))) (is (= {:type :notification-recipient/raw-value :details {:value "boss@example.com"}} (last recipients)))))))))) - (testing "no admin email appended when admin-email setting is nil" (let [sent (atom nil) recips [(admin-group-recipient) diff --git a/enterprise/backend/test/metabase_enterprise/security_center/settings_test.clj b/enterprise/backend/test/metabase_enterprise/security_center/settings_test.clj index 27b7642f5305..13fd1b37ac3c 100644 --- a/enterprise/backend/test/metabase_enterprise/security_center/settings_test.clj +++ b/enterprise/backend/test/metabase_enterprise/security_center/settings_test.clj @@ -36,19 +36,15 @@ {:value [{:type "notification-recipient/user" :user_id (mt/user->id :crowberto) :details nil}]}) (is (= [{:type "notification-recipient/user" :user_id (mt/user->id :crowberto) :details nil}] (mt/user-http-request :crowberto :get 200 "setting/security-center-email-recipients")))) - (testing "rejects null" (mt/user-http-request :crowberto :put 400 "setting/security-center-email-recipients" {:value nil})) - (testing "rejects empty list" (mt/user-http-request :crowberto :put 400 "setting/security-center-email-recipients" {:value []})) - (testing "non-superuser gets 403" (mt/user-http-request :rasta :put 403 "setting/security-center-email-recipients" {:value [{:type "notification-recipient/user" :user_id (mt/user->id :crowberto) :details nil}]})))) - (testing "requires premium feature" (mt/with-premium-features #{} (mt/user-http-request :crowberto :put 500 "setting/security-center-email-recipients" @@ -61,24 +57,20 @@ (mt/with-temporary-setting-values [slack-token-valid? false] (mt/user-http-request :crowberto :put 400 "setting/security-center-slack-channel" {:value "#security"}))) - (testing "superuser can set channel when Slack is configured" (mt/with-temporary-setting-values [slack-token-valid? true] (mt/user-http-request :crowberto :put 204 "setting/security-center-slack-channel" {:value "#security"}) (is (= "#security" (mt/user-http-request :crowberto :get 200 "setting/security-center-slack-channel"))))) - (testing "superuser can set to null (disable Slack)" (mt/user-http-request :crowberto :put 204 "setting/security-center-slack-channel" {:value nil}) (mt/user-http-request :crowberto :get 204 "setting/security-center-slack-channel")) - (testing "non-superuser gets 403" (mt/with-temporary-setting-values [slack-token-valid? true] (mt/user-http-request :rasta :put 403 "setting/security-center-slack-channel" {:value "#security"}))))) - (testing "requires premium feature" (mt/with-premium-features #{} (mt/user-http-request :crowberto :put 500 "setting/security-center-slack-channel" diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/api_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/api_test.clj index d8c7d6b42872..56a0027bd852 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/api_test.clj @@ -27,19 +27,16 @@ (let [{:keys [indexed_count total_est]} (mt/user-http-request :crowberto :get 200 "ee/semantic-search/status")] (is (= 0 indexed_count)) (is (= expected-search-items-count total_est)))) - (testing "Correctly reports size of index after inserting documents" (semantic.tu/upsert-index! (semantic.tu/mock-documents)) (let [{:keys [indexed_count total_est]} (mt/user-http-request :crowberto :get 200 "ee/semantic-search/status")] (is (= 2 indexed_count)) (is (= expected-search-items-count total_est))))))))) - (testing "with semantic search disabled" (mt/with-premium-features #{} (let [response (mt/user-http-request :crowberto :get 402 "ee/semantic-search/status")] (testing "returns 402 when semantic search feature is not available" (is (= 402 (get-in response [:data :status-code]))))))) - (testing "with no active index" (mt/with-premium-features #{:semantic-search} (with-redefs [semantic.env/get-pgvector-datasource! (constantly nil) @@ -53,7 +50,6 @@ (mt/with-premium-features #{:semantic-search} (testing "admin users can access status endpoint" (mt/user-http-request :crowberto :get 200 "ee/semantic-search/status")) - (testing "regular users cannot access status endpoint" (mt/user-http-request :rasta :get 403 "ee/semantic-search/status"))))) @@ -67,30 +63,22 @@ (#'semantic.pgvector-api/fresh-index semantic.tu/mock-index-metadata semantic.tu/mock-embedding-model :force-reset? true)) new-table-name (:table-name new-index) pgvector (semantic.env/get-pgvector-datasource!)] - (is (semantic.tu/table-exists-in-db? original-table-name)) (is (not (semantic.tu/table-exists-in-db? new-table-name))) - (let [best-index (semantic.index-metadata/find-compatible-index! pgvector semantic.tu/mock-index-metadata semantic.tu/mock-embedding-model)] (is (=? original-index (:index best-index))) (is (:active best-index))) - (testing "re-init creates the new index" (with-redefs [semantic.index/model-table-suffix (constantly 345)] (let [response (mt/user-http-request :crowberto :post 200 "search/re-init")] (is (contains? response :message)))) - (is (not= original-table-name new-table-name)) (is (semantic.tu/table-exists-in-db? original-table-name)) (is (semantic.tu/table-exists-in-db? new-table-name)) - (is (zero? (semantic.tu/index-count new-index)))) - (let [best-index (semantic.index-metadata/find-compatible-index! pgvector semantic.tu/mock-index-metadata semantic.tu/mock-embedding-model)] (is (=? new-index (:index best-index))) (is (:active best-index))) - (testing "Index can be populated after re-init" (semantic.tu/upsert-index! (semantic.tu/mock-documents) :index new-index) - (is (pos? (semantic.tu/index-count new-index))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/core_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/core_test.clj index 69c36924762c..b30870bb3ff0 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/core_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/core_test.clj @@ -152,11 +152,9 @@ (let [results (semantic.core/results search-ctx)] (testing "semantic result comes first" (is (= semantic-result (first results)))) - (testing "fallback results are appended, and duplicate model/id pairs are removed" (is (= (rest fallback-results) (rest results)))) - (testing "Results metrics are collected" (is (= 4 (:metabase-search/semantic-fallback-results-usage @metrics))) (is (= 1 (:metabase-search/semantic-fallback-triggered @metrics))) diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/db_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/db_test.clj index f6b239e03d57..703d02155f63 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/db_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/db_test.clj @@ -12,19 +12,15 @@ (when semantic.db.datasource/db-url ;; Reset the data source to ensure clean test (reset! semantic.db.datasource/data-source nil) - (testing "Data source is nil before initialization" (is (nil? @semantic.db.datasource/data-source))) - (testing "init-db! creates a pooled data source" (semantic.db.datasource/init-db!) (is (some? @semantic.db.datasource/data-source)) (is (instance? PoolBackedDataSource @semantic.db.datasource/data-source))) - (testing "test-connection! works with pooled connection" (let [result (semantic.db.datasource/test-connection!)] (is (= {:test 1} result)))) - (testing "Connection pool properties are configured correctly" (when (instance? PoolBackedDataSource @semantic.db.datasource/data-source) (let [pool ^PoolBackedDataSource @semantic.db.datasource/data-source] diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/dlq_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/dlq_test.clj index 2ebca765388a..389720d19c63 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/dlq_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/dlq_test.clj @@ -35,12 +35,10 @@ (is (= :permanent (semantic.dlq/categorize-error (ex-info "Validation error" {:status 422})))) (is (= :transient (semantic.dlq/categorize-error (ex-info "Server error" {:status 500})))) (is (= :transient (semantic.dlq/categorize-error (ex-info "Gateway timeout" {:status 504}))))) - (testing "Exception types" (is (= :transient (semantic.dlq/categorize-error (SocketException. "Connection reset")))) (is (= :permanent (semantic.dlq/categorize-error (AssertionError. "Invalid assertion")))) (is (= :permanent (semantic.dlq/categorize-error (NullPointerException. "NPE"))))) - (testing "Default to transient" (is (= :transient (semantic.dlq/categorize-error (RuntimeException. "Unknown error")))) (is (= :transient (semantic.dlq/categorize-error (Exception. "Generic exception")))))) @@ -76,9 +74,7 @@ :attempt_at t2 :last_attempted_at t1 :error_gated_at t1}]] - (is (= 2 (semantic.dlq/add-entries! pgvector index-metadata index-id entries))) - (let [table-name (semantic.dlq/dlq-table-name-kw index-metadata index-id) results (jdbc/execute! pgvector (sql/format {:select [:*] :from [table-name] :order-by [:gate_id]} :quoted true) @@ -86,7 +82,6 @@ (is (= 2 (count results))) (is (= "gate1" (:gate_id (first results)))) (is (= 0 (:retry_count (first results))))) - (let [table-name (semantic.dlq/dlq-table-name-kw index-metadata index-id) all-results (jdbc/execute! pgvector (sql/format {:select [[:gate_id :id] [:error_gated_at :gated_at]] :from [table-name]} :quoted true) @@ -110,16 +105,13 @@ :attempt_at t1 :last_attempted_at t2 :error_gated_at t1}]] - (is (= 1 (semantic.dlq/add-entries! pgvector index-metadata index-id initial-entry))) - (let [updated-entry [{:gate_id "gate1" :retry_count 2 :attempt_at t2 :last_attempted_at t2 :error_gated_at t2}]] (is (= 1 (semantic.dlq/add-entries! pgvector index-metadata index-id updated-entry)))) - (let [table-name (semantic.dlq/dlq-table-name-kw index-metadata index-id) results (jdbc/execute! pgvector (sql/format {:select [:*] :from [table-name] :order-by [:gate_id]} :quoted true) @@ -141,17 +133,14 @@ c2 {:model "card" :id "2" :name "Test" :searchable_text "Content" :embeddable_text "Content"} version semantic.gate/search-doc->gate-doc delete (fn [doc t] (semantic.gate/deleted-search-doc->gate-doc (:model doc) (:id doc) t))] - (with-open [_ (semantic.tu/open-metadata! pgvector index-metadata) _ (semantic.tu/open-index! pgvector index) index-id-ref (semantic.tu/closeable (semantic.index-metadata/record-new-index-table! pgvector index-metadata index) (constantly nil)) _ (open-dlq! pgvector index-metadata @index-id-ref)] - ;; Set up test data: gate entry and DLQ entry (semantic.gate/gate-documents! pgvector index-metadata [(version c1 t1) (delete c2 t2)]) - ;; Add some DLQ entries that should be retried (let [dlq-entries [;; upsert {:gate_id "card_1" @@ -172,23 +161,19 @@ :last_attempted_at t2 :error_gated_at t1}]] (semantic.dlq/add-entries! pgvector index-metadata @index-id-ref dlq-entries)) - (testing "poll at different times finds records to be retried as-of that clock value" (testing "t1" (with-redefs [semantic.dlq/clock (reify InstantSource (instant [_] (.toInstant t1)))] (let [poll-results (semantic.dlq/poll pgvector index-metadata @index-id-ref 100)] (is (= {"card_3" 1} (frequencies (map :id poll-results))))))) - (testing "t2" (with-redefs [semantic.dlq/clock (reify InstantSource (instant [_] (.toInstant t2)))] (let [poll-results (semantic.dlq/poll pgvector index-metadata @index-id-ref 100)] (is (= {"card_1" 1 "card_3" 1} (frequencies (map :id poll-results))))))) - (testing "t3" (with-redefs [semantic.dlq/clock (reify InstantSource (instant [_] (.toInstant t3)))] (let [poll-results (semantic.dlq/poll pgvector index-metadata @index-id-ref 100)] (is (= {"card_1" 1 "card_2" 1 "card_3" 1} (frequencies (map :id poll-results)))))))) - (testing "limit parameter" (with-redefs [semantic.dlq/clock (reify InstantSource (instant [_] (.toInstant t3)))] (is (= 1 (count (semantic.dlq/poll pgvector index-metadata @index-id-ref 1)))) @@ -210,58 +195,46 @@ :update-candidates (map #(semantic.dlq/initial-dlq-entry % (.instant clock))) (semantic.dlq/add-entries! pgvector index-metadata index-id)))] - (with-open [_ (semantic.tu/open-metadata! pgvector index-metadata) _ (semantic.tu/open-index! pgvector index) index-id-ref (semantic.tu/closeable (semantic.index-metadata/record-new-index-table! pgvector index-metadata index) (constantly nil)) _ (open-dlq! pgvector index-metadata @index-id-ref)] - (with-redefs [semantic.dlq/clock clock] - (testing "exits with no data" (let [result (semantic.dlq/dlq-retry-loop! pgvector index-metadata index @index-id-ref :max-run-duration (Duration/ofSeconds 1))] (is (= :no-more-data (:exit-reason result))) (is (zero? (:success-count result))) (is (zero? (:failure-count result))))) - (testing "processes successful retries" ;; Set up gate data and DLQ entries for retry (is (pos? (semantic.gate/gate-documents! pgvector index-metadata [(version c1 t1)]))) - (add-gate-to-dlq! pgvector index-metadata @index-id-ref) - (testing "clock has not advanced beyond initial backoff" (is (= 0 (count (semantic.dlq/poll pgvector index-metadata @index-id-ref 10))))) - (testing "advance clock beyond initial backoff" ;; move clock passed the expected back off time (vreset! clock-ref (.plus (.instant clock) semantic.dlq/initial-backoff)) (is (= 1 (count (semantic.dlq/poll pgvector index-metadata @index-id-ref 10))))) - (let [result (semantic.dlq/dlq-retry-loop! pgvector index-metadata index @index-id-ref :max-run-duration (Duration/ofMinutes 5) :max-batch-size 10)] (is (= :no-more-data (:exit-reason result))) (is (= 1 (:success-count result))))) - (testing "handles failures and adjusts batch size" ;; ensure there are two documents in the gate (semantic.gate/gate-documents! pgvector index-metadata [(version c1 t1) (version c2 t1)]) ;; add everything to dlq (add-gate-to-dlq! pgvector index-metadata @index-id-ref) - (vreset! clock-ref (.plus (.instant clock) semantic.dlq/initial-backoff)) - (with-redefs [semantic.index/upsert-index! (fn [& _] (throw (RuntimeException. "Forced failure")))] (let [result (semantic.dlq/dlq-retry-loop! pgvector index-metadata index @index-id-ref :max-run-duration (Duration/ofSeconds 1) :max-batch-size 10)] (is (#{:no-more-data :ran-out-of-time} (:exit-reason result))) (is (= 2 (:failure-count result))))) - (testing "batch shrinking to minimum size of 1, causing passes" (add-gate-to-dlq! pgvector index-metadata @index-id-ref) (vreset! clock-ref (.plus (.instant clock) semantic.dlq/initial-backoff)) @@ -280,7 +253,6 @@ (is (= 2 (:success-count result))) ; both writes eventually succeed (is (= 2 (:failure-count result))) ; first batch (is (= [2 1 1] @observed-batches)))))) - (testing "exits once max-run time elapses, even if dlq is full" (add-gate-to-dlq! pgvector index-metadata @index-id-ref) (vreset! clock-ref (.plus (.instant clock) semantic.dlq/initial-backoff)) @@ -319,28 +291,23 @@ :document nil ; deletion :gated_at (ts "2025-01-04T09:00:00Z") :error_gated_at (ts "2025-01-04T09:00:00Z")}]] - (with-open [_ (semantic.tu/open-metadata! pgvector index-metadata) _ (semantic.tu/open-index! pgvector index)] - (testing "batch processing singles out orphans (dlq entry with no associated gate record)" (let [outcome (semantic.dlq/try-batch! pgvector index gate-docs)] (is (= {"card_1" 1 "card_2" 1} (frequencies (map :id (:successes outcome))))) (is (= {} (frequencies (map :gate_id (:failures outcome))))))) - (testing "failures across upsert/delete are aggregated" (with-redefs [semantic.index/upsert-index! (fn [& _] (throw (RuntimeException. "Upsert failed"))) semantic.index/delete-from-index! (fn [& _] (throw (RuntimeException. "Delete failed")))] (let [outcome (semantic.dlq/try-batch! pgvector index gate-docs)] (is (= {"card_1" 1 "card_2" 1} (frequencies (map (comp :gate_id :dlq-entry) (:failures outcome))))) (is (= {} (frequencies (map :id (:successes outcome)))))))) - (testing "partial failure is representable" (with-redefs [semantic.index/upsert-index! (fn [& _] (throw (RuntimeException. "Upsert failed")))] (let [outcome (semantic.dlq/try-batch! pgvector index gate-docs)] (is (= {"card_1" 1} (frequencies (map (comp :gate_id :dlq-entry) (:failures outcome))))) (is (= {"card_2" 1} (frequencies (map :id (:successes outcome)))))))) - (testing "failure increments retry count" (let [now (Instant/parse "2025-01-01T13:14:33Z")] (doseq [[ex policy] [[(RuntimeException. "Upsert failed") semantic.dlq/transient-policy] diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/embedding_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/embedding_test.clj index 1e7d3956b6de..96e233e990e4 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/embedding_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/embedding_test.clj @@ -32,7 +32,6 @@ :model-name "Snowflake/snowflake-arctic-embed-l-v2.0" :vector-dimensions 1024} (embedding/get-configured-model)))) - (mt/with-temporary-setting-values [ee-embedding-provider "ollama" ee-embedding-model "mxbai-embed-large" ee-embedding-model-dimensions 1024] @@ -40,7 +39,6 @@ :model-name "mxbai-embed-large" :vector-dimensions 1024} (embedding/get-configured-model)))) - (mt/with-temporary-setting-values [ee-embedding-provider "openai" ee-embedding-model "text-embedding-3-small" ee-embedding-model-dimensions 1536] @@ -53,7 +51,6 @@ (testing "model-dimensions uses setting defaults when override is nil" (mt/with-temporary-setting-values [ee-embedding-model-dimensions nil] (is (= 1024 (:vector-dimensions (embedding/get-configured-model)))))) - (testing "model-dimensions uses override when specified" (mt/with-temporary-setting-values [ee-embedding-model-dimensions 768] (is (= 768 (:vector-dimensions (embedding/get-configured-model))))))) @@ -84,20 +81,17 @@ (testing "create-batches handles empty input" (is (empty? (#'embedding/create-batches 10 count []))) (is (empty? (#'embedding/create-batches 10 count nil)))) - (testing "create-batches with single short text" (let [texts ["Short text"] batches (#'embedding/create-batches 10 count texts)] (is (= 1 (count batches))) (is (= texts (first batches))))) - (testing "create-batches splits texts appropriately with smaller token limit" ;; Use a smaller token limit to make testing more predictable (let [texts ["This is document 1" "This is document 2" "This is document 3"] batches (#'embedding/create-batches 10 #'embedding/count-tokens texts)] (is (= [["This is document 1" "This is document 2"] ["This is document 3"]] batches)))) - (testing "create-batches skips texts that exceed token limit" (let [texts ["Short" "This is a much longer text that exceeds the limit" "Also short"] batches (#'embedding/create-batches 5 #'embedding/count-tokens texts)] @@ -131,7 +125,6 @@ base64-str (encode-floats-to-base64 test-embedding)] (is (=? [#(float-vectors-approx= test-embedding %)] (decode [{:embedding base64-str}]))))) - (testing "extracts multiple embeddings correctly" (let [embedding1 [1.0 2.0 3.0] embedding2 [-1.0 -2.0 -3.0] @@ -145,28 +138,22 @@ (decode [{:embedding base64-str1} {:embedding base64-str2} {:embedding base64-str3}]))))) - (testing "edge cases that return empty results" (testing "handles empty data array" (is (= [] (decode []))) (is (= [] (decode nil)))) - (testing "handles zero-length embedding" (is (= [[]] (decode [{:embedding (encode-floats-to-base64 [])}]))))) - (testing "handles large embedding vectors" (let [large-embedding (vec (map float (range 1536))) base64-str (encode-floats-to-base64 large-embedding)] (is (=? [#(float-vectors-approx= large-embedding %)] (decode [{:embedding base64-str}]))))) - (testing "error cases" (testing "invalid base64 string throws exception" (is (thrown? Exception (decode [{:embedding "not-valid-base64!@#$"}])))) - (testing "non-string embedding value throws exception" (is (thrown? Exception (decode [{:embedding 123}])))) - (testing "base64 string with invalid byte count (not multiple of 4) returns nil" ;; Create a base64 string that decodes to 3 bytes (let [encoder (Base64/getEncoder) @@ -195,9 +182,7 @@ {:provider "ollama" :mock-response {:embedding mock-embedding} :counts-tokens? false}]] - (t2/delete! :model/SemanticSearchTokenTracking) - (mt/with-dynamic-fn-redefs [analytics/inc! (fn [metric & args] (swap! analytics-calls conj [metric args])) http/post (fn post-mock [_url & _options] @@ -305,10 +290,8 @@ indexing-state (semantic.indexer/init-indexing-state metadata-row) gate-docs (mapv #(semantic.gate/search-doc->gate-doc % (java.sql.Timestamp. 1000)) (semantic.tu/mock-documents))] - (semantic.gate/gate-documents! pgvector index-metadata gate-docs) (t2/delete! :model/SemanticSearchTokenTracking) - (testing "Indexing tokens are tracked" (semantic.indexer/indexing-step pgvector index-metadata index indexing-state) (is (= 1 (t2/count :model/SemanticSearchTokenTracking))) @@ -316,7 +299,6 @@ (t2/select-one :model/SemanticSearchTokenTracking)] (is (= :index request_type)) (is (= 13 total_tokens)))) - (testing "Querying tokens are tracked" (t2/delete! :model/SemanticSearchTokenTracking) (semantic.index/query-index pgvector index {:search-string "elephant"}) diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/gate_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/gate_test.clj index 74b993eacf9c..529e68d596b9 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/gate_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/gate_test.clj @@ -49,7 +49,6 @@ :document_hash (u/encode-base64-bytes (buddy-hash/sha1 (json/encode (into (sorted-map) search-doc)))) :updated_at (:updated_at search-doc)} (sut search-doc t2))) - (testing "uses default updated_at when search doc has none" (is (= t2 (:updated_at (sut (dissoc search-doc :updated_at) t2)) @@ -104,7 +103,6 @@ version semantic.gate/search-doc->gate-doc delete (fn [doc t] (semantic.gate/deleted-search-doc->gate-doc (:model doc) (:id doc) t)) sut semantic.gate/gate-documents!] - (with-open [_ (open-tables! pgvector index-metadata)] (testing "brand new index, writes accepted" (let [docs [(version c1 t1) (version d1 t1)] @@ -120,47 +118,36 @@ (is (<= (inst-ms existing-timestamp) gated_at (inst-ms new-timestamp))))))) - (testing "same doc a second time is ignored" (let [previous-state (get-gate-rows! pgvector index-metadata)] (is (= 0 (sut pgvector index-metadata [(version c1 t1) (version d1 t1)]))) (is (= (map comparable-gate-row previous-state) (map comparable-gate-row (get-gate-rows! pgvector index-metadata)))))) - (testing "if document has a newer timestamp, but the same content - not updated" (is (= 0 (sut pgvector index-metadata [(version c1 t2)])))) - (testing "if document has an older timestamp and new content, not updated" (is (= 0 (sut pgvector index-metadata [(version c2 t0)])))) - (testing "if document has same timestamp and new content, updated" (let [lower-bound (pg-clock-timestamp! pgvector)] (is (= 1 (sut pgvector index-metadata [(version c2 t1)]))) (is (<= (inst-ms lower-bound) (inst-ms (:gated_at (get-gate-row! pgvector index-metadata (:id (version c2 t1))))))))) - (testing "if document has newer timestamp and new content, updated" (is (= 1 (sut pgvector index-metadata [(version c1 t2)])))) - (testing "documents are not deleted if older" (let [previous-state (get-gate-rows! pgvector index-metadata)] (is (= 0 (sut pgvector index-metadata [(delete d1 t0)]))) (is (= (map comparable-gate-row previous-state) (map comparable-gate-row (get-gate-rows! pgvector index-metadata)))))) - (testing "documents are deleted if they have the same or newer timestamp" (is (= 1 (sut pgvector index-metadata [(delete d1 t1)]))) (is (= 1 (sut pgvector index-metadata [(delete c1 t3)])))) - (testing "documents are not deleted if already deleted, regardless of timestamp" (is (= 0 (sut pgvector index-metadata [(delete d1 t2)])))) - (testing "documents are not undeleted if new write is older than the delete" (is (= 0 (sut pgvector index-metadata [(version d1 t0)])))) - (testing "documents are undeleted if new write is newer than the delete" (is (= 1 (sut pgvector index-metadata [(version d1 t3)])))) - (testing "last logical update is preferred if multiple submitted, regardless of order" (is (= 1 (sut pgvector index-metadata [(version c1 t2) (version c3 t3) @@ -183,9 +170,7 @@ delete (fn [doc t] (semantic.gate/deleted-search-doc->gate-doc (:model doc) (:id doc) t)) gate! semantic.gate/gate-documents! sut semantic.gate/poll] - (with-open [_ (open-tables! pgvector index-metadata)] - (testing "empty gate table" (let [clock-lower-bound (inst-ms (pg-clock-timestamp! pgvector)) poll-result (sut pgvector index-metadata epoch-watermark) @@ -196,25 +181,20 @@ (is (not= epoch-watermark (semantic.gate/next-watermark epoch-watermark poll-result))) (is (<= (inst-ms (:poll-time poll-result)) (inst-ms (:poll-time next-poll-result)))) (is (= [] (:update-candidates poll-result)))))) - (testing "from epoch happy path" (testing "add a doc, picked up" (gate! pgvector index-metadata [(version c2 t0)]) (let [poll-result (sut pgvector index-metadata epoch-watermark)] (is (= {(:id (version c2 t0)) 1} (frequencies (map :id (:update-candidates poll-result))))))) - (testing "add a new doc and delete, all 3 operations are picked up from epoch" (gate! pgvector index-metadata [(version c1 t0) (delete c3 t1)]) (let [poll-result (sut pgvector index-metadata epoch-watermark)] (is (= (frequencies (map :id [(version c2 t0) (version c1 t0) (delete c3 t1)])) (frequencies (map :id (:update-candidates poll-result))))))) - (testing "limit works, will get the earlier record (c2 write)" ; note remains undefined within a timestamp (let [poll-result (sut pgvector index-metadata epoch-watermark :limit 1)] (is (= [(:id (version c2 t0))] (map :id (:update-candidates poll-result)))))) - (testing "watermark test" - ;; assign unique gate timestamps to each record, so following assertions are deterministic (doseq [[t {:keys [id]}] (map vector [t0 @@ -227,7 +207,6 @@ :set {:gated_at t} :where [:= :id id]} :quoted true))) - (let [[g1 g2 g3] (sort (map :gated_at (get-gate-rows! pgvector index-metadata))) lag-tolerance (Duration/ofSeconds 3) poll-times #(sort (map :gated_at (:update-candidates (sut pgvector index-metadata % :lag-tolerance lag-tolerance)))) @@ -256,20 +235,17 @@ :update-candidates [{:id "card_123" :document_hash "foo" :gated_at (ts "2025-01-01T12:30:00Z")} {:id "dashboard_456" :document_hash nil :gated_at (ts "2025-01-01T12:45:00Z")}]} next-watermark (semantic.gate/next-watermark initial-watermark poll-result)] - (is (= (ts "2025-01-01T13:00:00Z") (:last-poll next-watermark))) (is (= {:gated_at (ts "2025-01-01T12:45:00Z") :document_hash nil :id "dashboard_456"} (:last-seen next-watermark))))) - (testing "resume-watermark extracts watermark from metadata row" (let [metadata-row {:indexer_last_poll (ts "2025-01-01T12:00:00Z") :indexer_last_seen (ts "2025-01-01T11:30:00Z") :indexer_last_seen_id "card_1" :indexer_last_seen_hash "foo"} watermark (semantic.gate/resume-watermark metadata-row)] - (is (= (ts "2025-01-01T12:00:00Z") (:last-poll watermark))) (is (= {:id "card_1" :document_hash "foo" @@ -287,9 +263,7 @@ :last-seen {:id "card_1" :document_hash "bar" :gated_at (ts "2025-01-01T12:45:00Z")}}] - (with-open [_ (open-tables! pgvector index-metadata)] - (let [id1 (semantic.index-metadata/record-new-index-table! pgvector @@ -301,9 +275,7 @@ pgvector index-metadata index2)] - (semantic.gate/flush-watermark! pgvector index-metadata index2 watermark) - (let [indexer-records (->> (jdbc/execute! pgvector (-> {:select [:id diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/index_metadata_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/index_metadata_test.clj index c4f2967571e3..74bab719e487 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/index_metadata_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/index_metadata_test.clj @@ -90,9 +90,7 @@ ;; note: I might recommend this to throw if no control row, this behaviour is ok for now (sut pgvector index-metadata index-id1) (is (= [] (semantic.tu/get-control-rows pgvector index-metadata)))) - (semantic.index-metadata/ensure-control-row-exists! pgvector index-metadata) - (testing "sets specified index as active" (sut pgvector index-metadata index-id1) (is (=? [{:active_id index-id1}] (semantic.tu/get-control-rows pgvector index-metadata))) @@ -223,6 +221,5 @@ :table_name (:table-name index) :index_version (:version index)}] (semantic.tu/get-metadata-rows pgvector index-metadata))))) - (testing "enforces unique table-name constraint" (is (thrown-with-msg? Exception #"duplicate key" (sut pgvector index-metadata index))))))) diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/index_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/index_test.clj index 2bf18c526777..f635029f5cb1 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/index_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/index_test.clj @@ -166,15 +166,12 @@ (swap! realized inc) (assoc (first docs) :id id)))) (range 123 500))] - (testing "ensure upsert! and delete! don't realize the full reducible at once" (semantic.tu/check-index-has-no-mock-docs) - (testing "upsert-index!" (with-redefs [semantic.index/upsert-index-pooled! (only-first-call realized @#'semantic.index/upsert-index-pooled!)] (is (= {"card" 2} (semantic.tu/upsert-index! mock-docs)))) (semantic.tu/check-index-has-mock-card)) - (reset! realized 0) (testing "delete-from-index!" (with-redefs [semantic.index/delete-from-index-batch-sql (only-first-call realized @#'semantic.index/delete-from-index-batch-sql)] @@ -246,12 +243,10 @@ :content "Dog Training Guide" :embedding (semantic.tu/get-mock-embedding "Dog Training Guide")}] (semantic.tu/query-embeddings {:model "card" :model_id "1"}))) - (is (= [{:model "card" :model_id "2" :creator_id 2 :content "Elephant Migration" :embedding (semantic.tu/get-mock-embedding "Elephant Migration")}] (semantic.tu/query-embeddings {:model "card" :model_id "2"}))) - (is (= [{:model "card" :model_id "3" :creator_id 3 :content "Tiger Conservation" :embedding (semantic.tu/get-mock-embedding "Tiger Conservation")}] @@ -302,12 +297,10 @@ :content "Dog Training Guide" :embedding (semantic.tu/get-mock-embedding "Dog Training Guide")}] (semantic.tu/query-embeddings {:model "card" :model_id "1"}))) - (is (= [{:model "card" :model_id "2" :creator_id 2 :content "Elephant Migration" :embedding (semantic.tu/get-mock-embedding "Elephant Migration")}] (semantic.tu/query-embeddings {:model "card" :model_id "2"}))) - (is (= [{:model "card" :model_id "3" :creator_id 3 :content "Dog Training Guide" :embedding (semantic.tu/get-mock-embedding "Dog Training Guide")}] @@ -356,27 +349,23 @@ (mt/with-test-user :crowberto (semantic.index/query-index (semantic.env/get-pgvector-datasource!) semantic.tu/mock-index {:search-string "dog training"})) - (let [permission-calls (filter #(= :metabase-search/semantic-permission-filter-ms (first %)) @analytics-calls)] (is (= 1 (count permission-calls))) (let [time-ms (first (second (first permission-calls)))] (is (number? time-ms)) (is (< time-ms 1000) "Permission filtering should complete within 1000ms")))) - (testing "Semantic search timing metrics" (reset! analytics-calls []) (mt/with-test-user :crowberto (semantic.index/query-index (semantic.env/get-pgvector-datasource!) semantic.tu/mock-index {:search-string "elephant migration" :filter-items-in-personal-collection "only"})) - (let [metric-names (set (map first @analytics-calls))] (is (contains? metric-names :metabase-search/semantic-search-ms)) (is (contains? metric-names :metabase-search/semantic-embedding-ms)) (is (contains? metric-names :metabase-search/semantic-db-query-ms)) (is (contains? metric-names :metabase-search/semantic-permission-filter-ms)) (is (contains? metric-names :metabase-search/semantic-appdb-scores-ms))) - (testing "timing values are reasonable" (doseq [[metric args] @analytics-calls :when (#{:metabase-search/semantic-search-ms @@ -392,7 +381,6 @@ (is (number? time-ms) (str "Time for " metric " should be numeric")) (is (>= time-ms 0) (str "Time for " metric " should be non-negative")) (is (< time-ms 1000) (str "Time for " metric " should be under 1000ms"))))) - (let [embedding-metrics (filter #(#{:metabase-search/semantic-search-ms :metabase-search/semantic-embedding-ms :metabase-search/semantic-db-query-ms} (first %)) @analytics-calls)] @@ -407,26 +395,21 @@ (testing "filter-type 'all' returns nil (no filter)" (is (nil? (#'semantic.index/personal-collection-filter {:filter-items-in-personal-collection "all" :current-user-id user-id})))) - (testing "filter-type nil defaults to 'all' behavior" (is (nil? (#'semantic.index/personal-collection-filter {:filter-items-in-personal-collection nil :current-user-id user-id})))) - (testing "filter-type 'only-mine' returns only current user's personal collection items" (is (= [:= :personal_owner_id user-id] (#'semantic.index/personal-collection-filter {:filter-items-in-personal-collection "only-mine" :current-user-id user-id})))) - (testing "filter-type 'only' returns all personal collection items (any user)" (is (= [:is-not :personal_owner_id nil] (#'semantic.index/personal-collection-filter {:filter-items-in-personal-collection "only" :current-user-id user-id})))) - (testing "filter-type 'exclude' returns only shared and uncollected items" (is (= [:is :personal_owner_id nil] (#'semantic.index/personal-collection-filter {:filter-items-in-personal-collection "exclude" :current-user-id user-id})))) - (testing "filter-type 'exclude-others' returns user's personal items plus shared/uncollected items" (is (= [:or [:is :personal_owner_id nil] @@ -444,26 +427,21 @@ [:model/Collection {shared-coll-id :id} {:name "Shared Collection"} :model/Collection {user1-sub-coll-id :id} {:location (str "/" user1-personal-coll-id "/") :name "User1 Sub"} :model/Collection {user2-sub-coll-id :id} {:location (str "/" user2-personal-coll-id "/") :name "User2 Sub"}] - (testing "empty input returns empty map" (is (empty? (#'semantic.index/batch-resolve-personal-owner-ids []))) (is (empty? (#'semantic.index/batch-resolve-personal-owner-ids [nil])))) - (testing "shared collection is absent from result" (is (empty? (#'semantic.index/batch-resolve-personal-owner-ids [shared-coll-id])))) - (testing "personal collections map to their owners" (is (= {user1-personal-coll-id user1-id user2-personal-coll-id user2-id} (#'semantic.index/batch-resolve-personal-owner-ids [user1-personal-coll-id user2-personal-coll-id])))) - (testing "sub-collections of personal collections map to root personal owner" (is (= {user1-sub-coll-id user1-id user2-sub-coll-id user2-id} (#'semantic.index/batch-resolve-personal-owner-ids [user1-sub-coll-id user2-sub-coll-id])))) - (testing "mixed input resolves all in one call" (is (= {user1-personal-coll-id user1-id user2-sub-coll-id user2-id} @@ -484,23 +462,19 @@ {:id "3:789" :model "indexed-entity" :collection_id nil}]] - (testing "keeps all entities when user has root permissions" (binding [api/*current-user-permissions-set* (atom #{"/"})] (let [result (#'semantic.index/filter-read-permitted indexed-entity-docs)] (is (= 3 (count result)))))) - (testing "drops all entities when user has no permissions" (binding [api/*current-user-permissions-set* (atom #{})] (let [result (#'semantic.index/filter-read-permitted indexed-entity-docs)] (is (= 0 (count result)))))) - (testing "keeps only entities in readable collections" (binding [api/*current-user-permissions-set* (atom #{(format "/collection/%d/read/" readable-coll-id)})] (let [result (#'semantic.index/filter-read-permitted indexed-entity-docs)] (is (= 1 (count result))) (is (= "1:123" (:id (first result))))))) - (testing "memoizes permission check per collection_id across docs" (let [calls (atom 0) real-can-read? mi/can-read?] @@ -511,7 +485,6 @@ (#'semantic.index/filter-read-permitted (repeat 50 {:id "1:1" :model "indexed-entity" :collection_id readable-coll-id})) (is (= 1 @calls) "expected one can-read? call for the single distinct collection_id"))))) - (testing "handles empty input" (is (= [] (#'semantic.index/filter-read-permitted []))))))))) @@ -520,7 +493,6 @@ (testing "fast path dispatches through the per-search-model t2 model" (mt/with-temp [:model/Collection {coll-id :id} {}] (binding [api/*current-user-permissions-set* (atom #{"/"})] - (testing "card and dashboard in the same collection do NOT share a memo bucket" ;; Card dispatches through :model/Card, Dashboard through :model/Dashboard. Their ;; `can-read?` answers happen to agree today (both route through @@ -537,7 +509,6 @@ {:id 2 :model "dashboard" :collection_id coll-id}]) (is (= 2 @calls) "card and dashboard must dispatch separately even for the same collection_id")))) - (testing "card and indexed-entity share the :model/Card memo bucket" ;; indexed-entity is deliberately routed through :model/Card — its index row's ;; `collection_id` is the parent Card's, denormalized at ingest time. @@ -557,7 +528,6 @@ (testing "boolean inputs are returned unchanged" (is (true? (#'semantic.index/to-boolean true))) (is (false? (#'semantic.index/to-boolean false)))) - (testing "MySQL-style integer booleans are converted correctly" (is (false? (#'semantic.index/to-boolean 0))) (is (true? (#'semantic.index/to-boolean 1)))))) @@ -582,7 +552,6 @@ :embeddable_text "test content" :creator_id 1 :embedding embedding-vec}] - (testing "MySQL-style integer booleans are converted to real booleans" (let [doc-with-mysql-booleans (assoc base-doc :archived 0 @@ -594,7 +563,6 @@ (is (true? (:official_collection result))) (is (false? (:pinned result))) (is (true? (:verified result))))) - (testing "real boolean values are preserved" (let [doc-with-real-booleans (assoc base-doc :archived true @@ -606,7 +574,6 @@ (is (false? (:official_collection result))) (is (true? (:pinned result))) (is (false? (:verified result))))) - (testing "nil boolean fields are handled correctly" (let [doc-with-nil-booleans base-doc result (#'semantic.index/doc->db-record nil doc-with-nil-booleans)] diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/indexer_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/indexer_test.clj index 03ed0d96abdd..3e18c208144c 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/indexer_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/indexer_test.clj @@ -59,9 +59,7 @@ (reset! poll-calls []) (reset! upsert-calls []) (reset! delete-calls []))] - (is (= initial-watermark (:watermark @indexing-state))) - (testing "gate is empty, poll counts should be zero - and upsert/delete should not be called" (with-redefs [semantic.index/upsert-index! upsert-proxy semantic.index/delete-from-index! delete-proxy @@ -72,14 +70,12 @@ (is (= 1 (count @poll-calls))) (is (= 0 (:last-poll-count @indexing-state))) (is (= 0 (:last-novel-count @indexing-state))) - (testing "watermark has been updated" (let [[{poll-result :ret}] @poll-calls] (is (= (semantic.gate/next-watermark initial-watermark poll-result) (:watermark @indexing-state))) (testing "watermark has been flushed to the metadata table" (is (= (:watermark @indexing-state) (semantic.gate/resume-watermark (get-metadata-row! pgvector index-metadata index))))))))) - (testing "add some data to the gate we should see upsert / delete be called" (clear-spies) (semantic.gate/gate-documents! pgvector index-metadata [(version c1 t1) (delete c2 t2)]) @@ -92,14 +88,12 @@ (is (= 1 (count @poll-calls))) (is (= 2 (:last-poll-count @indexing-state))) (is (= 2 (:last-novel-count @indexing-state))) - (testing "watermark has been updated" (let [[{poll-result :ret}] @poll-calls] (is (= (semantic.gate/next-watermark initial-watermark poll-result) (:watermark @indexing-state))) (testing "watermark has been flushed to the metadata table" (is (= (:watermark @indexing-state) (semantic.gate/resume-watermark (get-metadata-row! pgvector index-metadata index))))))))) - (testing "stepping again with no new data does nothing" (clear-spies) (with-redefs [semantic.index/upsert-index! upsert-proxy @@ -111,7 +105,6 @@ (is (= 1 (count @poll-calls))) (is (= 2 (:last-poll-count @indexing-state))) (is (= 0 (:last-novel-count @indexing-state))))) - (testing "add some more data, picked up" (clear-spies) (semantic.gate/gate-documents! pgvector index-metadata [(version c2 t3)]) @@ -123,7 +116,6 @@ (is (= 0 (count @delete-calls))) (is (= 1 (count @poll-calls))) (is (= 1 (:last-novel-count @indexing-state))))) - (testing "exceptions during indexing are bubbled, and the watermark position is preserved" (clear-spies) (semantic.gate/gate-documents! pgvector index-metadata [(version c3 t3)]) @@ -136,7 +128,6 @@ (is (= 1 (count @poll-calls))) (testing "state/watermark was not updated" (is (= previous-state @indexing-state))))) - (testing "exceptions are recovered from" (clear-spies) (with-redefs [semantic.index/upsert-index! upsert-proxy @@ -159,11 +150,9 @@ (let [current-sleep (mt/metric-value system :metabase-search/semantic-indexer-sleep-ms)] (is (< @sleep-metric-state current-sleep)) (vreset! sleep-metric-state current-sleep))))] - (testing "high novelty ratio (>25%) - no sleep" (with-redefs [semantic.indexer/sleep (fn [ms] (throw (ex-info "Should not sleep" {:ms ms})))] (is (nil? (semantic.indexer/on-indexing-idle indexing-state))))) - (testing "medium novelty ratio (10-25%) - small backoff" (vswap! indexing-state assoc :last-novel-count 15) ; 15% novelty (let [sleep-called (atom nil)] @@ -172,9 +161,7 @@ (reset! sleep-called ms)) #_#(reset! sleep-called %)] (semantic.indexer/on-indexing-idle indexing-state) (is (= 250 @sleep-called)) - (test-sleep-metric)))) - (testing "low novelty ratio (1-10%) - medium backoff" (vswap! indexing-state assoc :last-novel-count 5) ; 5% novelty (let [sleep-called (atom nil)] @@ -183,9 +170,7 @@ (reset! sleep-called ms)) #_#(reset! sleep-called %)] (semantic.indexer/on-indexing-idle indexing-state) (is (= 1500 @sleep-called)) - (test-sleep-metric)))) - (testing "very low novelty ratio (<1%) - big backoff" (vswap! indexing-state assoc :last-novel-count 0) ; 0% novelty (let [sleep-called (atom nil)] @@ -233,7 +218,6 @@ :else (recur))))) step-called (promise) idle-called (promise)] - (with-redefs [semantic.indexer/on-indexing-idle (fn [_] (deliver idle-called true) (swap! call-counter update :idle inc) @@ -245,7 +229,6 @@ (when-some [ex @step-ex] (throw ex)) (when (.isInterrupted (Thread/currentThread)) (throw (InterruptedException.))))] - (with-open [loop-thread (open-loop-thread! pgvector index-metadata @@ -254,25 +237,20 @@ (let [{:keys [caught-ex ^Thread thread]} @loop-thread] (is (not= :timeout (deref idle-called 100 :timeout))) (is (not= :timeout (deref step-called 100 :timeout))) - (testing "start time set" (is (inst? (:start-time @indexing-state)))) - (testing "called repeatedly" (is (wait-for-calls))) - (testing "called 1 to 1" (let [{:keys [idle step]} @call-counter lower (dec idle) upper (inc idle)] (is (<= lower step upper)))) - (testing "exceptions are bubbled out of loop, will not loop forever" (let [ex (Exception. (str "Error: " (u/generate-nano-id)))] (vreset! step-ex ex) (is (.join thread (Duration/ofSeconds 1)) "loop exits") (is (= (ex-message ex) (ex-message @caught-ex))))))) - (testing "loop interruptible" (with-open [loop-thread (open-loop-thread! pgvector @@ -380,7 +358,6 @@ (.interrupt thread) (when-not (.join thread (Duration/ofSeconds 30)) (log/fatal "Indexing loop thread not exiting during test!")))))))] - (testing "exit immediately: no active index / index tables" (with-open [job-thread ^Closeable (open-job-thread pgvector index-metadata)] (let [{:keys [caught-ex ^Thread thread]} @job-thread] @@ -388,7 +365,6 @@ (is (.join thread (Duration/ofSeconds 5)))) (testing "did not crash" (is (nil? @caught-ex)))))) - (testing "runs loop on active index then exits" (let [loop-args (atom []) metadata-row {:indexer_last_poll (ts "2025-01-01T12:00:00Z") @@ -407,7 +383,6 @@ (is (= @(semantic.indexer/init-indexing-state metadata-row) @indexing-state)))) (testing "did not crash" (is (nil? @caught-ex)))))))) - (doseq [ex [(Exception. "Boom") (AssertionError. "Assert") (InterruptedException. "Interrupt")]] @@ -420,7 +395,6 @@ (is (.join thread (Duration/ofSeconds 5)))) (testing "crashed with expected msg" (is (= (ex-message ex) (ex-message @caught-ex))))))))) - (testing "initial dlq run is scheduled if table exists" (let [loop-args (atom []) metadata-row {:id 42 @@ -468,7 +442,6 @@ (semantic.index-metadata/record-new-index-table! pgvector index-metadata index) (constantly nil)) _ (open-dlq! pgvector index-metadata @index-id-ref)] - (testing "dlq rescheduled after no change" (let [original-last-seen-change (Instant/parse "2025-01-12T03:22:45Z") {:keys [last-seen-change next-dlq-run]} @@ -481,7 +454,6 @@ (testing ":last-seen-change not modified - would allow cold exit" (is (= original-last-seen-change last-seen-change))) (is (= expected-next-dlq-run next-dlq-run)))) - (testing "dlq rescheduled after change (no more data, should back off)" (let [{:keys [last-seen-change next-dlq-run]} (run-scenario {:dlq-loop-return {:exit-reason :no-more-data, @@ -494,7 +466,6 @@ (testing "Metrics have expected values" (is (== 1 (mt/metric-value system :metabase-search/semantic-indexer-dlq-successes))) (is (== 0 (mt/metric-value system :metabase-search/semantic-indexer-dlq-failures)))))) - (testing "dlq rescheduled immediately after change (ran out of time, more to do)" (let [{:keys [last-seen-change next-dlq-run]} (run-scenario {:dlq-loop-return {:exit-reason :ran-out-of-time @@ -506,7 +477,6 @@ (testing "Metrics have expected values" (is (== 2 (mt/metric-value system :metabase-search/semantic-indexer-dlq-successes))) (is (== 0 (mt/metric-value system :metabase-search/semantic-indexer-dlq-failures)))))) - ;; for now policy for this branch is the same as the above - but may change (testing "dlq rescheduled immediately after change (ran out of time, more to do - failures)" (let [{:keys [last-seen-change next-dlq-run]} @@ -560,48 +530,37 @@ (semantic.index-metadata/record-new-index-table! pgvector index-metadata index) (constantly nil)) _ (open-dlq! pgvector index-metadata @index-id-ref)] - (with-redefs [semantic.indexer/clock clock semantic.dlq/clock clock ;; assume during this test that we are on the 'confident' gate poll branch. semantic.indexer/lag-tolerance Duration/ZERO] - (testing "normal indexing without stalls" (let [indexing-state (fresh-indexing-state)] (semantic.gate/gate-documents! pgvector index-metadata [(version (card 1) t1)]) (semantic.indexer/indexing-step pgvector index-metadata index indexing-state) - (is (nil? (:stalled-at @indexing-state))) (is (nil? (:indexer_stalled_at (get-metadata-row! pgvector index-metadata index)))))) - (testing "indexing failure marks as stalled" (let [indexing-state (fresh-indexing-state)] (semantic.gate/gate-documents! pgvector index-metadata [(version (card 2) t1)]) - (with-redefs [semantic.index/upsert-index! (fn [& _] (throw (RuntimeException. "Index failure")))] (is (thrown? RuntimeException (semantic.indexer/indexing-step pgvector index-metadata index indexing-state))) (is (some? (:indexer_stalled_at (get-metadata-row! pgvector index-metadata index))))) - (test-metric-growth)) - (testing "stalled indexing before grace period continues to throw" (let [indexing-state (fresh-indexing-state) stall-time (:indexer_stalled_at (get-metadata-row! pgvector index-metadata index)) initial-clock (.instant clock)] (is (some? stall-time)) (is (= stall-time (:stalled-at @indexing-state))) - ;; Advance clock but (just about) stay within grace period (vreset! clock-ref (.plus initial-clock (.minus semantic.indexer/stall-grace-period (Duration/ofSeconds 1)))) (with-redefs [semantic.index/upsert-index! (fn [& _] (throw (RuntimeException. "Still failing during grace period")))] (is (thrown? RuntimeException (semantic.indexer/indexing-step pgvector index-metadata index indexing-state)))) - ;; Stall time should not be overwritten (retains original lower value) (let [current-stall-time (:indexer_stalled_at (get-metadata-row! pgvector index-metadata index))] (is (= stall-time current-stall-time)))) - (test-metric-growth))) - (testing "recovery from stall clears stall status" (let [indexing-state (fresh-indexing-state) stall-time (:indexer_stalled_at (get-metadata-row! pgvector index-metadata index))] @@ -609,12 +568,9 @@ (semantic.gate/gate-documents! pgvector index-metadata [(version (card 3) t1)]) (semantic.indexer/indexing-step pgvector index-metadata index indexing-state) (is (nil? (:indexer_stalled_at (get-metadata-row! pgvector index-metadata index)))) - (test-metric-growth) - (testing ":metabase-search/semantic-indexer-stalled" (is (== 0 (mt/metric-value system :metabase-search/semantic-indexer-stalled)))))) - (testing "stalled indexing uses DLQ after grace period" (let [indexing-state (fresh-indexing-state) initial-watermark (:watermark @indexing-state)] @@ -623,23 +579,18 @@ (.plus semantic.indexer/stall-grace-period) (.plus (Duration/ofSeconds 1)))) (semantic.gate/gate-documents! pgvector index-metadata [(version (card 4) t1)]) - (with-redefs [semantic.index/upsert-index! (fn [& _] (throw (RuntimeException. "Still failing")))] (semantic.indexer/indexing-step pgvector index-metadata index indexing-state) - (testing "DLQ entries created" (let [dlq-rows (get-dlq-rows! pgvector index-metadata @index-id-ref)] (is (seq dlq-rows)) (is (= ["card_4"] (map :gate_id dlq-rows))))) - (testing "watermark progresses despite failures" (let [new-watermark (:watermark @indexing-state)] (is (not= initial-watermark new-watermark)) (is (= -1 (compare (:last-poll initial-watermark) (:last-poll new-watermark)))))) - (testing ":metabase-search/semantic-indexer-stalled" (is (== 1 (mt/metric-value system :metabase-search/semantic-indexer-stalled))))) - (testing ":metabase-search/semantic-indexer-poll-to-poll-interval-ms" (is (=? {:sum #(< 0 %) :count #(== 4 %) @@ -662,14 +613,12 @@ (sql/format {:select [:model_id] :from [(keyword (:table-name index))]} :quoted true) {:builder-fn jdbc.rs/as-unqualified-lower-maps})) upsert-index! semantic.index/upsert-index!] - (with-open [_ (semantic.tu/open-metadata! pgvector index-metadata) _ (semantic.tu/open-index! pgvector index) index-id-ref (semantic.tu/closeable (semantic.index-metadata/record-new-index-table! pgvector index-metadata index) (constantly nil)) _ (open-dlq! pgvector index-metadata @index-id-ref)] - (with-redefs [semantic.indexer/lag-tolerance Duration/ZERO semantic.indexer/dlq-frequency Duration/ZERO ; every loop iteration semantic.indexer/dlq-max-run-duration (Duration/ofSeconds 2) @@ -686,18 +635,14 @@ (when (seq docs) ;; Insert into actual index (upsert-index! pgvector index docs)))] - (testing "indexer loop with poisoned document uses DLQ correctly" (let [good-docs [(card 1 "Good Doc 1") (card 2 "Good Doc 2") (card 3 "Good Doc 3")] bad-doc (card 4 "Poisoned Doc") all-docs (conj good-docs bad-doc)] - ;; Set up poisoned document (vreset! poisoned-doc-id (str (:id bad-doc))) - ;; Add all documents to gate (semantic.gate/gate-documents! pgvector index-metadata (map #(version % t1) all-docs)) - ;; Create indexing state with short durations and DLQ scheduling (let [fresh-indexing-state (fn [] (doto (semantic.indexer/init-indexing-state (get-metadata-row! pgvector index-metadata index)) @@ -705,14 +650,12 @@ :max-run-duration (Duration/ofSeconds 3) :exit-early-cold-duration (Duration/ofSeconds 3) :next-dlq-run (.instant clock))))] - (testing "Initial loop will exit with the expected exception" (with-open [loop-thread (open-loop-thread! pgvector index-metadata index (fresh-indexing-state))] (let [{:keys [^Thread thread caught-ex]} @loop-thread] (is (.join thread (Duration/ofSeconds 5)) "dies in a reasonable amount of time") (is (= "Poisoned document" (ex-message @caught-ex))) (is (some? (:indexer_stalled_at (get-metadata-row! pgvector index-metadata index))))))) - (testing "dead letter queue is drained, everything can eventually be indexed" (with-open [loop-thread (open-loop-thread! pgvector index-metadata index (fresh-indexing-state))] (let [{:keys [^Thread thread]} @loop-thread] @@ -720,18 +663,15 @@ (is (mt/poll-until 1000 (nil? (:indexer_stalled_at (get-metadata-row! pgvector index-metadata index)))))) - (testing "good docs get indexed right away, despite the poison" (is (mt/poll-until 1000 (= (frequencies (map :id good-docs)) (frequencies (map :model_id (get-indexed-docs))))))) - (testing "only 1 dlq entry left" (is (mt/poll-until 1000 (= 1 (count (get-dlq-rows! pgvector index-metadata @index-id-ref)))))) - (testing "clear the poison" (vreset! poisoned-doc-id nil) (testing "all docs get indexed" @@ -739,12 +679,10 @@ 1000 (= (frequencies (map :id all-docs)) (frequencies (map :model_id (get-indexed-docs)))))))) - (testing "dlq drained" (is (mt/poll-until 1000 (empty? (get-dlq-rows! pgvector index-metadata @index-id-ref))))) - (.interrupt thread) (is (.join thread (Duration/ofSeconds 1)) "dies in a reasonable amount of time"))))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/pgvector_api_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/pgvector_api_test.clj index dcec06566b7d..09a1cb4de0b5 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/pgvector_api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/pgvector_api_test.clj @@ -115,7 +115,6 @@ (is (= [{:args [pgvector @index-ref documents] :ret ret}] @calls)))) - (testing "check proxies after switch" (reset! calls []) (let [new-index (semantic.pgvector-api/init-semantic-search! pgvector index-metadata model2) @@ -251,9 +250,7 @@ job-thread ^Closeable (open-job-thread pgvector index-metadata)] (let [index @index-ref {:keys [caught-ex ^Thread thread]} @job-thread] - (is (= (frequencies (map :model docs)) (semantic.pgvector-api/gate-updates! pgvector index-metadata docs))) - (let [max-wait (+ (System/currentTimeMillis) 1000) get-indexed-q {:select [:model [:model_id :id]] :from [(keyword (:table-name index))]} get-indexed (fn [] (frequencies @@ -269,10 +266,8 @@ (< max-wait (System/currentTimeMillis)) indexed (= indexed expected-indexed) indexed :else (recur (get-indexed))))] - (testing "we indexed all the expected documents" (is (= expected-indexed indexed-in-time)))) - (testing "interrupt" (.interrupt thread) (is (.join thread (Duration/ofSeconds 10))) diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/query_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/query_test.clj index 502815d0eb06..aeea49a7e54d 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/query_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/query_test.clj @@ -21,7 +21,6 @@ (let [results (-> (semantic.tu/query-index {:search-string "puppy"}) semantic.tu/filter-for-mock-embeddings)] (is (= "Dog Training Guide" (-> results first :name))))) - (testing "Bird-related query finds bird content" (let [results (-> (semantic.tu/query-index {:search-string "avian"}) semantic.tu/filter-for-mock-embeddings)] @@ -114,13 +113,11 @@ filtered-results (semantic.tu/filter-for-mock-embeddings card-results)] (is (pos? (count filtered-results))) (is (every? #(= "card" (:model %)) card-results)))) - (testing "Filter for dashboards only" (let [dashboard-results (semantic.tu/query-index {:search-string "marine mammal" :models ["dashboard"]}) filtered-results (semantic.tu/filter-for-mock-embeddings dashboard-results)] (is (pos? (count filtered-results))) (is (every? #(= "dashboard" (:model %)) dashboard-results)))) - (testing "Filter for multiple model types" (let [mixed-results (semantic.tu/query-index {:search-string "predator" :models ["card" "dashboard"]}) filtered-results (semantic.tu/filter-for-mock-embeddings mixed-results)] @@ -135,13 +132,11 @@ (testing "Include only non-archived items" (let [active-results (semantic.tu/query-index {:search-string "feline" :archived? false})] (is (every? #(not (:archived %)) active-results)))) - (testing "Include only archived items" (let [archived-results (semantic.tu/query-index {:search-string "feline" :archived? true}) filtered-results (semantic.tu/filter-for-mock-embeddings archived-results)] (is (pos? (count filtered-results))) (is (every? :archived archived-results)))) - (testing "Include all items regardless of archived status" (let [all-results (semantic.tu/query-index {:search-string "feline"}) filtered-results (semantic.tu/filter-for-mock-embeddings all-results)] @@ -157,7 +152,6 @@ filtered-results (semantic.tu/filter-for-mock-embeddings crowberto-results)] (is (pos? (count filtered-results))) (is (every? #(= (mt/user->id :crowberto) (:creator_id %)) crowberto-results)))) - (testing "Filter by multiple creators" (let [multi-creator-results (semantic.tu/query-index {:search-string "endangered species" :created-by [(mt/user->id :crowberto) (mt/user->id :rasta)]})] @@ -174,7 +168,6 @@ filtered-results (semantic.tu/filter-for-mock-embeddings verified-results)] (is (pos? (count filtered-results))) (is (every? :verified verified-results)))) - (testing "Include all items regardless of verified status when filter not specified" (let [all-results (semantic.tu/query-index {:search-string "puppy"}) filtered-results (semantic.tu/filter-for-mock-embeddings all-results)] @@ -195,7 +188,6 @@ (is (every? #(and (= "card" (:model %)) (not (:archived %)) (= (mt/user->id :rasta) (:creator_id %))) results)))) - (testing "Archived dashboards by multiple creators" (let [results (semantic.tu/query-index {:search-string "Antarctic wildlife" :models ["dashboard"] @@ -206,7 +198,6 @@ (is (every? #(and (= "dashboard" (:model %)) (:archived %) (contains? #{(mt/user->id :crowberto) (mt/user->id :rasta)} (:creator_id %))) results)))) - (testing "Verified items with additional filters" (let [results (semantic.tu/query-index {:search-string "marine mammal" :models ["dashboard"] @@ -260,7 +251,6 @@ (let [results (-> (semantic.tu/query-index {:search-string "marine mammal"}) semantic.tu/filter-for-mock-embeddings)] (is (= "Whale Communication" (-> results first :name))))) - (testing "Tiger-related query finds tiger content" (let [results (-> (semantic.tu/query-index {:search-string "endangered species"}) semantic.tu/filter-for-mock-embeddings)] diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/repair_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/repair_test.clj index 203caa0e29ed..069d5eb305bc 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/repair_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/repair_test.clj @@ -69,21 +69,18 @@ (create-test-document "dashboard" 3 "Animal Stats")]] (semantic.core/update-index! initial-docs) (semantic.tu/index-all!) - ;; Ensure repair-index! brings index into consistency with new documents et. ;; Note: card 2 and dashboard 3 are missing - should be deleted (let [modified-docs [(create-test-document "card" 1 "Dog Training Guide") ; existing - should remain (create-test-document "card" 4 "Bird Watching Tips") ; new - should be added (create-test-document "dashboard" 5 "Wildlife Dashboard")]] ; new - should be added (semantic.core/repair-index! modified-docs) - (let [gate-contents (gate-table-contents pgvector gate-table) new-card-entry (gate-entry-by-id gate-contents "card_4") new-dashboard-entry (gate-entry-by-id gate-contents "dashboard_5")] (testing "new documents should be gated for insertion" (is (some? (:document new-card-entry)) "New card should exist in gate table") (is (some? (:document new-dashboard-entry)) "New dashboard should exist in gate table")) - (testing "missing documents should be gated for deletion" (let [deleted-card-entry (gate-entry-by-id gate-contents "card_2") deleted-dashboard-entry (gate-entry-by-id gate-contents "dashboard_3")] @@ -100,12 +97,10 @@ initial-docs [(create-test-document "card" 6 "Dog Training Guide")]] (semantic.core/update-index! initial-docs) (semantic.tu/index-all!) - (testing "repair table is cleaned up after successful repair" (let [test-repair-table-name "repair_table_cleanup_test"] (with-redefs [semantic.repair/repair-table-name (constantly test-repair-table-name)] (semantic.core/repair-index! [(create-test-document "card" 7 "New Test Card")]) - (let [gate-contents (gate-table-contents pgvector gate-table)] (is (tombstone? (gate-entry-by-id gate-contents "card_6"))) (is (some? (gate-entry-by-id gate-contents "card_7"))) diff --git a/enterprise/backend/test/metabase_enterprise/semantic_search/scoring_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/scoring_test.clj index b799bcbeede0..df309a731498 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/scoring_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/scoring_test.clj @@ -269,7 +269,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" @@ -281,7 +280,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/enterprise/backend/test/metabase_enterprise/semantic_search/task/index_cleanup_test.clj b/enterprise/backend/test/metabase_enterprise/semantic_search/task/index_cleanup_test.clj index 5f1ff8e193fb..efd2a74715c7 100644 --- a/enterprise/backend/test/metabase_enterprise/semantic_search/task/index_cleanup_test.clj +++ b/enterprise/backend/test/metabase_enterprise/semantic_search/task/index_cleanup_test.clj @@ -99,7 +99,6 @@ (is (semantic.tu/table-exists-in-db? orphaned-table)) (is (semantic.tu/table-exists-in-db? recent-table)) (is (semantic.tu/table-exists-in-db? active-table)) - ;; Run cleanup function and ensure only the stale & orphaned tables is dropped (with-redefs [semantic.env/get-pgvector-datasource! (constantly pgvector) semantic.env/get-index-metadata (constantly index-metadata)] @@ -109,13 +108,11 @@ (is (not (semantic.tu/table-exists-in-db? orphaned-table))) (is (semantic.tu/table-exists-in-db? recent-table)) (is (semantic.tu/table-exists-in-db? active-table)) - (finally (jdbc/execute! pgvector [(str "DROP TABLE IF EXISTS " recent-table)]) (jdbc/execute! pgvector [(str "DROP TABLE IF EXISTS " stale-table)]) (jdbc/execute! pgvector [(str "DROP TABLE IF EXISTS " orphaned-table)]) (jdbc/execute! pgvector [(str "DROP TABLE IF EXISTS " active-table)]))))) - (testing "handles non-existent tables gracefully" (let [nonexistent-table "nonexistent_table"] (insert-metadata-with-timestamps! pgvector index-metadata @@ -160,7 +157,6 @@ :document_hash nil}]) (sql/format :quoted true))) (cleanup-old-tombstones! pgvector index-metadata) - ;; Verify no records were deleted since indexer hasn't run recently (let [remaining-records (jdbc/execute! pgvector (-> (sql.helpers/select [:*]) @@ -170,7 +166,6 @@ {:builder-fn jdbc.rs/as-unqualified-lower-maps}) remaining-ids (map :id remaining-records)] (is (contains? (set remaining-ids) "old-tombstone-1")))) - (testing "when indexer has run recently, cleans up old tombstone records and preserves recent ones and non-tombstones" ;; Update metadata for active index to have recent indexer_last_poll time (let [active-index-metadata (semantic.index-metadata/get-active-index-state pgvector index-metadata)] @@ -215,7 +210,6 @@ :document_hash "hash123"}]) (sql/format :quoted true))) (cleanup-old-tombstones! pgvector index-metadata) - ;; Verify only old tombstones were deleted after cleanup task runs (let [remaining-records (jdbc/execute! pgvector (-> (sql.helpers/select [:*]) @@ -238,7 +232,6 @@ parsed-time (#'sut/parse-repair-table-timestamp repair-table-name)] (is (= (t/truncate-to old-time :millis) parsed-time)))) - (testing "orphan repair table detection and cleanup" (let [old-repair-table-name (with-redefs [t/instant (constantly old-time)] (#'repair/repair-table-name)) @@ -249,14 +242,11 @@ (jdbc/execute! pgvector [(format "CREATE TABLE \"%s\" (id INT)" old-repair-table-name)]) (jdbc/execute! pgvector [(format "CREATE TABLE \"%s\" (id INT)" recent-repair-table-name)]) (jdbc/execute! pgvector [(format "CREATE TABLE \"%s\" (id INT)" non-repair-table-name)]) - (with-redefs [semantic.settings/repair-table-retention-hours (constantly retention-hours)] (let [orphan-tables (#'sut/orphan-repair-tables pgvector)] (is (= #{old-repair-table-name} (set orphan-tables)) "Only old repair table should be detected as orphan")) - (#'sut/cleanup-orphan-repair-tables! pgvector) - (is (not (semantic.tu/table-exists-in-db? old-repair-table-name)) "Old repair table should be dropped") (is (semantic.tu/table-exists-in-db? recent-repair-table-name) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/api_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/api_test.clj index ee08740fee9c..05f421e3900a 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/api_test.clj @@ -138,13 +138,11 @@ :all_collections false :data_model false :settings true)] (is (= #{:log :settings :transform :python-library} (tar-file-types f))))) - (testing "We can export just a single collection" (let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {} :collection (:id coll) :data_model false :settings false)] (is (= #{:log :collection-entity :transform :python-library} (tar-file-types f))))) - (testing "We can export two collections" (let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {} :collection (:id coll) :collection (:id coll2) @@ -152,25 +150,21 @@ (is (some #(= :collection-entity (first %)) (tar-file-types f true)) "Export should contain collection entities"))) - (testing "We can export that collection using entity id" (let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {} ;; eid:... syntax is kept for backward compat :collection (str "eid:" (:entity_id coll)) :data_model false :settings false)] (is (= #{:log :collection-entity :transform :python-library} (tar-file-types f))))) - (testing "We can export that collection using entity id" (let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {} :collection (:entity_id coll) :data_model false :settings false)] (is (= #{:log :collection-entity :transform :python-library} (tar-file-types f))))) - (testing "Default export: all-collections, data-model, settings" (let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {})] (is (= #{:transform :log :collection-entity :settings :schema :database :python-library} (tar-file-types f))))) - (testing "On exception API returns tar.gz with error in export.log" (mt/with-dynamic-fn-redefs [serdes/extract-one (extract-one-error (:entity_id card) (mt/dynamic-value serdes/extract-one))] @@ -181,12 +175,10 @@ (testing "export.log inside the archive contains error details" (is (some? log) "export.log should be present in the archive") (is (re-find #"deliberate error message" log))))))) - (testing "You can pass specific directory name" (let [f (mt/user-http-request :crowberto :post 200 "ee/serialization/export" {} :dirname "check" :all_collections false :data_model false :settings false)] (is (str/starts-with? (first-entry-name f) "check/"))))) - (testing "Invalid entity ID returns an error instead of falling back to root collection" (let [fake-eid "abcdefghijklmnopqrstu" res (mt/user-http-request :crowberto :post 400 "ee/serialization/export" {} @@ -220,14 +212,12 @@ (search/delete! :model/Card [(str (:id card))]) (is (= 0 (search-result-count "dashboard" "thraddash"))) (is (= 0 (search-result-count "dataset" "frobinate"))) - (let [res (-> (mt/user-http-request :crowberto :post 200 "ee/serialization/export" :collection (:id coll) :data_model false :settings false) io/input-stream) ba (#'api.serialization/ba-copy res)] (testing "Archive contains correct number of files" (is (= 13 (count (filter #(not (str/ends-with? % "/")) (entry-names ba)))))) - (testing "Snowplow export event was sent" (is (=? {"event" "serialization" "direction" "export" @@ -251,16 +241,13 @@ ;; Clear entities from search index (search/delete! :model/Dashboard [(str (:id dash))]) (search/delete! :model/Card [(str (:id card))]) - ;; Export the data (let [ba (do-export (:id coll))] ;; Pop the export snowplow event (snowplow-test/pop-event-data-and-user-id!) - ;; Modify entities in the database (t2/update! :model/Dashboard {:id (:id dash)} {:name "urquan"}) (t2/delete! :model/Card (:id card)) - (let [re-indexed? (atom false) _res (mt/with-dynamic-fn-redefs [search/reindex! (fn [& _] (reset! re-indexed? true) (future nil))] (mt/user-http-request :crowberto :post 200 "ee/serialization/import?reindex=false" @@ -269,7 +256,6 @@ (testing "Entities are restored in the database" (is (= (:name dash) (t2/select-one-fn :name :model/Dashboard :entity_id (:entity_id dash)))) (is (= (:name card) (t2/select-one-fn :name :model/Card :entity_id (:entity_id card))))) - (testing "Snowplow import event was sent" (is (=? {"event" "serialization" "direction" "import" @@ -281,10 +267,8 @@ "success" true "error_message" nil} (-> (snowplow-test/pop-event-data-and-user-id!) last :data)))) - (testing "Full reindex was not triggered (reindex=false)" (is (false? @re-indexed?))) - (testing "Entities are added to the search index" (is (= 1 (search-result-count "dashboard" "thraddash"))) (is (= 0 (search-result-count "dashboard" "urquan"))) @@ -296,7 +280,6 @@ (let [ba (do-export (:id coll))] ;; Pop the export snowplow event (snowplow-test/pop-event-data-and-user-id!) - (mt/with-dynamic-fn-redefs [v2.ingest/ingest-file (let [ingest-file (mt/dynamic-value #'v2.ingest/ingest-file)] (fn [^File file] (cond-> (ingest-file file) @@ -309,7 +292,6 @@ log (slurp (io/input-stream res))] (testing "Error message indicates missing collection" (is (re-find #"Collection 'DoesNotExist' was not found" log))) - (testing "Snowplow failure event was sent" (is (=? {"success" false "event" "serialization" @@ -327,7 +309,6 @@ (let [ba (do-export (:id coll))] ;; Pop the export snowplow event (snowplow-test/pop-event-data-and-user-id!) - (mt/with-dynamic-fn-redefs [v2.ingest/ingest-file (let [ingest-file (mt/dynamic-value #'v2.ingest/ingest-file)] (fn [^File file] (cond-> (ingest-file file) @@ -340,7 +321,6 @@ log (slurp (io/input-stream res))] (testing "Log contains the missing-collection error" (is (re-find #"Collection 'DoesNotExist' was not found" log))) - (testing "Snowplow event shows partial success with error count" (is (=? {"success" true "event" "serialization" @@ -378,7 +358,6 @@ :table :report_card :cause "[test] deliberate error message"} (extract-and-sanitize-exception-map log)))))) - (testing "Snowplow failure event was sent" (is (=? {"event" "serialization" "direction" "export" @@ -394,7 +373,6 @@ "success" false "error_message" #"(?s)Error extracting Card \d+ .*"} (-> (snowplow-test/pop-event-data-and-user-id!) last :data)))) - (testing "full_stacktrace parameter includes full stack trace in export.log" (binding [api.serialization/*additive-logging* false] (let [res (mt/user-http-request :crowberto :post 200 "ee/serialization/export" @@ -415,7 +393,6 @@ :continue_on_error true)] (testing "Log contains the deliberate error" (is (re-find #"deliberate error message" (read-export-log res))))) - (testing "Snowplow event shows partial success with error count" (is (=? {"event" "serialization" "direction" "export" @@ -439,7 +416,6 @@ (testing "Non-admin cannot export" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 "ee/serialization/export")))) - (testing "Non-admin cannot import" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 "ee/serialization/import" @@ -675,7 +651,6 @@ (let [ba (do-export (:id coll))] ;; Consume the export response (snowplow-test/pop-event-data-and-user-id!) - ;; Do an import to exercise that code path too (let [res (mt/user-http-request :crowberto :post 200 "ee/serialization/import" {:request-options {:headers {"content-type" "multipart/form-data"}}} @@ -683,7 +658,6 @@ ;; Consume the import response (slurp (io/input-stream res)) (snowplow-test/pop-event-data-and-user-id!)))) - ;; Verify no new files were left behind ;; if this breaks, check if you consumed every response with io/input-stream. Or `future` is taking too long ;; in `api/on-response!`, so maybe add some Thread/sleep here. diff --git a/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj index d06192da72bb..417f926dd7e4 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/cmd_test.clj @@ -52,7 +52,6 @@ (->> (map :data (snowplow-test/pop-event-data-and-user-id!)) (filter #(= "serialization" (get % "event"))) first)))) - (testing "Snowplow import event was sent" (cmd/import dump-dir) (is (=? {"event" "serialization" @@ -64,7 +63,6 @@ "success" true "error_message" nil} (-> (snowplow-test/pop-event-data-and-user-id!) first :data)))) - (with-redefs [v2.storage/store! (fn [_stream _backend] (throw (Exception. "Cannot load settings")))] (is (thrown? Exception @@ -86,7 +84,6 @@ (->> (map :data (snowplow-test/pop-event-data-and-user-id!)) (filter #(= "serialization" (get % "event"))) first))))) - (let [ingest-file @#'v2.ingest/ingest-file] ;; overriding ingest-file is weird, but ingest-one is a protocol function and with-redefs won't ;; override that reliably diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/backfill_ids_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/backfill_ids_test.clj index e85391d66bdd..7ddf138659a1 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/backfill_ids_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/backfill_ids_test.clj @@ -15,17 +15,14 @@ :location (str "/" c1-id "/")} :model/Collection {c4-id :id} {:name "child collection" :location (str "/" c2-id "/")}] - (let [coll-ids [c1-id c2-id c3-id c4-id] all-eids #(t2/select-fn-set :entity_id :model/Collection :id [:in coll-ids])] (testing "all collections have entity_ids" (is (every? some? (all-eids)))) - (testing "removing the entity_ids" (doseq [id coll-ids] (t2/update! :model/Collection id {:entity_id nil})) (is (every? nil? (all-eids)))) - (testing "backfill now recreates them" (serdes.backfill/backfill-ids-for! :model/Collection) (is (every? some? (all-eids)))))))) @@ -38,7 +35,6 @@ (t2/update! :model/Collection c2-id {:entity_id nil}) (is (= #{c1-eid nil} (t2/select-fn-set :entity_id :model/Collection :type nil)))) - (testing "backfill" (serdes.backfill/backfill-ids-for! :model/Collection) (testing "sets a blank entity_id" @@ -54,7 +50,6 @@ (t2/update! :model/Collection c2-id {:entity_id nil}) (is (= #{c1-eid nil} (t2/select-fn-set :entity_id :model/Collection :type nil)))) - (testing "backfilling twice" (serdes.backfill/backfill-ids-for! :model/Collection) (let [first-eid (t2/select-one-fn :entity_id :model/Collection :id c2-id)] diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj index 530047a81f86..8261db3f54a1 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/e2e_test.clj @@ -204,7 +204,6 @@ :timeline (many-random-fks 10 {} {:creator_id [:u 10] :collection_id [:coll 100]}) :timeline-event (many-random-fks 90 {} {:timeline_id [:timeline 10]})})) - (is (= 101 (count (t2/select-fn-set :email 'User)))) ; +1 for the internal user (testing "extraction" @@ -215,13 +214,10 @@ {} @extraction)) ;; +1 for the Trash collection (is (= 110 (-> @entities (get "Collection") count)))) - (testing "storage" (storage/store! (seq @extraction) (storage.files/file-writer dump-dir)) - (testing "for Actions" (is (= 30 (count (dir->file-set (io/file dump-dir "actions")))))) - (testing "for Collections" ;; +1 for the Trash collection (let [colls-dir (io/file dump-dir "collections") @@ -231,10 +227,8 @@ ;; +1 for Trash collection; exact count may vary by 1 depending on naming collisions (is (<= 109 coll-count 111) "which all go in collections/, even the snippets ones"))) - (testing "for Databases" (is (= 10 (count (dir->dir-set (io/file dump-dir "databases")))))) - (testing "for Tables" (is (= 100 (reduce + (for [db (dir->dir-set (io/file dump-dir "databases")) @@ -242,7 +236,6 @@ :when (.exists tables-dir)] (count (dir->dir-set tables-dir))))) "Tables are scattered, so the directories are harder to count")) - (testing "for Fields" (is (= 1000 (reduce + (for [db (dir->dir-set (io/file dump-dir "databases")) @@ -251,7 +244,6 @@ :when (.exists fields-dir)] (count (dir->file-set fields-dir))))) "Fields are scattered, so the directories are harder to count")) - (testing "for cards, dashboards, and timelines" ;; In the new storage format, cards/dashboards/timelines are stored directly ;; in collection directories (no per-type subfolders). @@ -262,14 +254,12 @@ (is (<= 269 (count (for [f (file-set main-dir) :when (not= "Collection" (yaml-model-at main-dir f))] f)) 271)))) - (testing "for segments" (is (= 30 (reduce + (for [db (dir->dir-set (io/file dump-dir "databases")) table (dir->dir-set (io/file dump-dir "databases" db "tables")) :let [segments-dir (io/file dump-dir "databases" db "tables" table "segments")] :when (.exists segments-dir)] (count (dir->file-set segments-dir))))))) - (testing "for native query snippets" ;; Snippets are now under collections/snippets/ (not a top-level snippets/ dir). ;; Count non-collection yaml files under collections/snippets/. @@ -277,10 +267,8 @@ (is (= 10 (count (for [f (file-set snippets-dir) :when (not= "Collection" (yaml-model-at snippets-dir f))] f)))))) - (testing "for settings" (is (.exists (io/file dump-dir "settings.yaml"))))) - (testing "ingest and load" (ts/with-db dest-db (testing "ingested set matches extracted set" @@ -289,29 +277,24 @@ (count @extraction))) (is (= extracted-set (set (ingest/ingest-list (ingest/ingest-yaml dump-dir))))))) - (testing "doing ingestion" (is (serdes/with-cache (serdes.load/load-metabase! (ingest/ingest-yaml dump-dir))) "successful")) - (testing "for Actions" (doseq [{:keys [entity_id] :as coll} (get @entities "Action")] (is (= (clean-entity coll) (-> (ts/extract-one "Action" entity_id) clean-entity))))) - (testing "for Collections" (doseq [{:keys [entity_id] :as coll} (get @entities "Collection")] (is (= (clean-entity coll) (-> (ts/extract-one "Collection" entity_id) clean-entity))))) - (testing "for Databases" (doseq [{:keys [name] :as db} (get @entities "Database")] (is (= (assoc (clean-entity db) :initial_sync_status "complete") (-> (ts/extract-one "Database" [:= :name name]) clean-entity))))) - (testing "for Tables" (doseq [{:keys [db_id name] :as coll} (get @entities "Table")] (is (= (clean-entity coll) @@ -319,7 +302,6 @@ [:= :name name] [:= :db_id (t2/select-one-pk 'Database :name db_id)]]) clean-entity))))) - (testing "for Fields" (doseq [{[db schema table] :table_id name :name :as coll} (get @entities "Field")] (is (nil? schema)) @@ -328,55 +310,46 @@ (is (= (clean-entity coll) (-> (ts/extract-one "Field" [:and [:= :name name] [:= :table_id table]]) clean-entity)))))) - (testing "for cards" (doseq [{:keys [entity_id] :as card} (get @entities "Card")] (is (= (clean-entity card) (-> (ts/extract-one "Card" entity_id) clean-entity))))) - (testing "for dashboards" (doseq [{:keys [entity_id] :as dash} (get @entities "Dashboard")] (is (= (clean-entity dash) (-> (ts/extract-one "Dashboard" entity_id) clean-entity))))) - (testing "for dashboard cards" (doseq [{:keys [entity_id] :as dashcard} (get @entities "DashboardCard")] (is (= (clean-entity dashcard) (-> (ts/extract-one "DashboardCard" entity_id) clean-entity))))) - (testing "for dimensions" (doseq [{:keys [entity_id] :as dim} (get @entities "Dimension")] (is (= (clean-entity dim) (-> (ts/extract-one "Dimension" entity_id) clean-entity))))) - (testing "for segments" (doseq [{:keys [entity_id] :as segment} (get @entities "Segment")] (is (= (clean-entity segment) (-> (ts/extract-one "Segment" entity_id) clean-entity))))) - (testing "for measures" (doseq [{:keys [entity_id] :as measure} (get @entities "Measure")] (is (= (clean-entity measure) (-> (ts/extract-one "Measure" entity_id) clean-entity))))) - (testing "for native query snippets" (doseq [{:keys [entity_id] :as snippet} (get @entities "NativeQuerySnippet")] (is (= (clean-entity snippet) (-> (ts/extract-one "NativeQuerySnippet" entity_id) clean-entity))))) - (testing "for timelines and events" (doseq [{:keys [entity_id] :as timeline} (get @entities "Timeline")] (is (= (clean-entity timeline) (-> (ts/extract-one "Timeline" entity_id) clean-entity))))) - (testing "for settings" (let [settings (get @entities "Setting")] (is (every? setting/export? @@ -422,11 +395,9 @@ ;; card_id is in a different collection with dashboard's collection :values_source_config {:card_id (:id card1s) :value_field [:field (:id field1s) nil]}}]}] - (testing "make sure we insert ParameterCard when insert Dashboard/Card" ;; one for parameter on card card2s, and one for parameter on dashboard dash1s (is (= 2 (t2/count :model/ParameterCard)))) - (testing "extract and store" (let [extraction (serdes/with-cache (into [] (extract/extract {})))] (is (= [{:id "abc", @@ -439,7 +410,6 @@ nil]}, :values_source_type :card}] (:parameters (first (by-model extraction "Dashboard"))))) - ;; card1s has no parameters, card2s does. (is (= #{[] [{:id "abc", @@ -452,16 +422,13 @@ nil]}, :values_source_type :card}]} (set (map :parameters (by-model extraction "Card"))))) - (storage/store! (seq extraction) (storage.files/file-writer dump-dir)))) - (testing "ingest and load" (ts/with-db dest-db ;; ingest (testing "doing ingestion" (is (serdes/with-cache (serdes.load/load-metabase! (ingest/ingest-yaml dump-dir))) "successful")) - (let [dash1d (t2/select-one :model/Dashboard :name (:name dash1s)) card1d (t2/select-one :model/Card :name (:name card1s)) card2d (t2/select-one :model/Card :name (:name card2s)) @@ -474,7 +441,6 @@ first :values_source_config))) (is (some? (t2/select-one :model/ParameterCard :parameterized_object_type "dashboard" :parameterized_object_id (:id dash1d))))) - (testing "parameter on card is loaded correctly" (is (= {:card_id (:id card1d), :value_field [:field (:id field1d) nil]} @@ -551,7 +517,6 @@ {:model "card" :id card-eid} {:model "dataset" :id model-eid}] (dashboard->link-cards extracted-dashboard))) - (is (= #{[{:id dash-eid :model "Dashboard"}] [{:id coll-eid :model "Collection"}] [{:id model-eid :model "Card"}] @@ -561,16 +526,13 @@ {:model "Schema" :id "Public"} {:model "Table" :id "Linked table"}]} (set (serdes/dependencies extracted-dashboard)))) - (storage/store! (seq extraction) (storage.files/file-writer dump-dir)))) - (testing "ingest and load" ;; ingest (ts/with-db dest-db (testing "doing ingestion" (is (serdes/with-cache (serdes.load/load-metabase! (ingest/ingest-yaml dump-dir))) "successful")) - (doseq [[name model] [[db-name 'Database] [table-name 'Table] @@ -579,7 +541,6 @@ [dash-name 'Dashboard]]] (testing (format "model %s from link cards are loaded properly" model) (is (some? (t2/select model :name name))))) - (testing "linkcards are loaded with correct fk" (let [new-db-id (t2/select-one-pk :model/Database :name db-name) new-table-id (t2/select-one-pk :model/Table :name table-name) @@ -680,7 +641,6 @@ :dashboard_tab_id tab-id-2}] (let [extraction (serdes/with-cache (into [] (extract/extract {})))] (storage/store! (seq extraction) (storage.files/file-writer dump-dir))) - (testing "ingest and load" (ts/with-db dest-db ;; ingest @@ -693,7 +653,6 @@ new-tab-id-2 (t2/select-one-pk :model/DashboardTab :entity_id tab-eid-2) new-card-id-1 (t2/select-one-pk :model/Card :entity_id card-eid-1) new-card-id-2 (t2/select-one-pk :model/Card :entity_id card-eid-2)] - (is (=? [{:id new-tab-id-1 :dashboard_id (:id new-dashboard) :name "Tab 1" @@ -733,14 +692,12 @@ :dashboard_id dashboard-id}] (let [extraction (serdes/with-cache (into [] (extract/extract {})))] (storage/store! (seq extraction) (storage.files/file-writer dump-dir))) - (testing "ingest and load" (ts/with-db dest-db ;; ingest (testing "doing ingestion" (is (serdes/with-cache (serdes.load/load-metabase! (ingest/ingest-yaml dump-dir))) "successful")) - (testing "The loaded card is a dashboard question, same as before" (let [new-dash-id (t2/select-one-pk :model/Dashboard :entity_id dashboard-eid) new-coll-id (t2/select-one-pk :model/Collection :entity_id coll-eid) @@ -784,14 +741,12 @@ (testing "export (v2-dump) command" (is (cmd/v2-dump! dump-dir {}) "works")) - (testing "import (v2-load) command" (ts/with-db dest-db (testing "doing ingestion" (mt/with-temp [:model/User _ {}] (is (cmd/v2-load! dump-dir {}) "works"))))))))))) - (testing "without :serialization feature enabled" (ts/with-random-dump-dir [dump-dir "serdesv2-"] (mt/with-premium-features #{} @@ -803,7 +758,6 @@ (is (thrown-with-msg? Exception #"Please upgrade" (cmd/v2-dump! dump-dir {})) "throws")) - (testing "import (v2-load) command" (ts/with-db dest-db (testing "doing ingestion" @@ -846,12 +800,10 @@ :orders.product_id %orders.product_id}) (reset! card1s card) (storage/store! (extract/extract {}) (storage.files/file-writer dump-dir)))))) - (ts/with-db dest-db ;; ensure there is something in db so that test-data gets different field ids for sure (mt/dataset office-checkins (mt/db)) - (mt/dataset test-data ;; ensuring field ids are stable by loading dataset in db first (mt/db) @@ -862,9 +814,7 @@ :products.title %products.title :orders.product_id %orders.product_id} @old-ids)) - (serdes.load/load-metabase! (ingest/ingest-yaml dump-dir)) - (let [viz (t2/select-one-fn :visualization_settings :model/Card :entity_id (:entity_id @card1s))] (testing "column names inside pivot table transferred" (is (= ["NAME"] @@ -885,9 +835,7 @@ _ (ts/create! :model/Card :name "card" :collection_id (:id coll))] (storage/store! (extract/extract {:no-settings true :no-data-model true}) (storage.files/file-writer dump-dir)) - (spit (io/file dump-dir "collections" ".hidden.yaml") "serdes/meta: [{do-not: read}]") - (testing "Hidden YAML files are still silently skipped" (let [{:keys [entities]} (#'ingest/ingest-all (io/file dump-dir)) files (->> entities @@ -895,7 +843,6 @@ (map #(.getName ^File %)) set)] (is (not (contains? files ".hidden.yaml"))))) - (testing "Unparseable non-hidden YAML files are collected as ingestion errors" (spit (io/file dump-dir "collections" "unreadable.yaml") "\0") (let [{:keys [errors]} (#'ingest/ingest-all (io/file dump-dir))] @@ -910,12 +857,10 @@ (storage/store! (extract/extract {:no-settings true :no-data-model true}) (storage.files/file-writer dump-dir)) (spit (io/file dump-dir "collections" "corrupt.yaml") "\0") - (testing "continue-on-error false (default) — throws on ingestion errors" (is (thrown-with-msg? Exception #"Failed to read 1 file\(s\) during ingestion: corrupt\.yaml" (serdes/with-cache (serdes.load/load-metabase! (ingest/ingest-yaml dump-dir)))))) - (testing "continue-on-error true — collects ingestion errors without throwing" (let [result (serdes/with-cache (serdes.load/load-metabase! (ingest/ingest-yaml dump-dir) @@ -1015,7 +960,6 @@ (dump/spit-yaml! (io/file coll-dir (str coll-eid "_test_collection.yaml")) coll-yaml) ;; Write settings.yaml (required) (dump/spit-yaml! (io/file dump-dir "settings.yaml") {}) - (testing "old-format files can be ingested and loaded" (let [ingestable (ingest/ingest-yaml dump-dir)] (is (serdes/with-cache (serdes.load/load-metabase! ingestable)) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj index 2cfc9776bdbc..a476f392f6dc 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/extract_test.clj @@ -46,27 +46,23 @@ coll-eid :entity_id coll-slug :slug} {:name "Some Collection"} - :model/Collection {child-id :id child-eid :entity_id child-slug :slug} {:name "Nested Collection" :location (format "/%s/" coll-id)} - :model/User {mark-id :id} {:first_name "Mark" :last_name "Knopfler" :email "mark@direstrai.ts"} - :model/Collection {pc-id :id pc-eid :entity_id pc-slug :slug} {:name "Mark's Personal Collection" :personal_owner_id mark-id}] - (testing "a top-level collection is extracted correctly" (let [ser (serdes/extract-one "Collection" {} (t2/select-one :model/Collection :id coll-id))] (is (=? {:serdes/meta [{:model "Collection" :id coll-eid :label coll-slug}]} @@ -75,7 +71,6 @@ (is (not (contains? ser :parent_id))) (is (not (contains? ser :location))) (is (not (contains? ser :id))))) - (testing "a nested collection is extracted with the right parent_id" (let [ser (serdes/extract-one "Collection" {} (t2/select-one :model/Collection :id child-id))] (is (=? {:serdes/meta [{:model "Collection" :id child-eid :label child-slug}] @@ -84,7 +79,6 @@ (is (not (contains? ser :personal_owner_id))) (is (not (contains? ser :location))) (is (not (contains? ser :id))))) - (testing "personal collections are extracted with email as key" (let [ser (serdes/extract-one "Collection" {} (t2/select-one :model/Collection :id pc-id))] (is (=? {:serdes/meta [{:model "Collection" :id pc-eid :label pc-slug}] @@ -93,16 +87,13 @@ (is (not (contains? ser :parent_id))) (is (not (contains? ser :location))) (is (not (contains? ser :id))))) - (testing "overall extraction returns the expected set" (testing "no user specified" (is (= #{coll-eid child-eid} (ids-by-model "Collection" (extract/extract nil))))) - (testing "valid user specified" (is (= #{coll-eid child-eid pc-eid} (ids-by-model "Collection" (extract/extract {:user-id mark-id}))))) - (testing "invalid user specified" (is (= #{coll-eid child-eid} (ids-by-model "Collection" (extract/extract {:user-id 218921}))))))))) @@ -114,52 +105,42 @@ {coll-id :id coll-eid :entity_id} {:name "Some Collection"} - :model/User {mark-id :id} {:first_name "Mark" :last_name "Knopfler" :email "mark@direstrai.ts"} - :model/User {dave-id :id} {:first_name "David" :last_name "Knopfler" :email "david@direstrai.ts"} - :model/Collection {mark-coll-eid :entity_id} {:name "MK Personal" :personal_owner_id mark-id} - :model/Collection {dave-coll-id :id dave-coll-eid :entity_id} {:name "DK Personal" :personal_owner_id dave-id} - :model/Database {db-id :id} {:name "My Database"} - :model/Table {no-schema-id :id} {:name "Schemaless Table" :db_id db-id} - :model/Field {field-id :id} {:name "Some Field" :table_id no-schema-id} - :model/Table {schema-id :id} {:name "Schema'd Table" :db_id db-id :schema "PUBLIC"} - :model/Field {field2-id :id} {:name "Other Field" :table_id schema-id} - :model/Card {c1-id :id c1-eid :entity_id} @@ -173,7 +154,6 @@ :aggregation [[:count]]} :type :query :database db-id}} - :model/Card {model-id :id} {:name "Some Model" @@ -187,7 +167,6 @@ :aggregation [[:count]]} :type :query :database db-id}} - :model/Card {c2-id :id c2-eid :entity_id} @@ -201,7 +180,6 @@ :card_id c1-id :target [:dimension [:field field-id {:source-field field2-id}]]}]} - :model/Card {c3-id :id c3-eid :entity_id} @@ -231,7 +209,6 @@ :enabled true}] :column_settings {(str "[\"ref\",[\"field\"," field2-id ",null]]") {:column_title "Locus"}}}} - :model/Card {c4-id :id c4-eid :entity_id} {:name "Referenced Question" :database_id db-id @@ -256,14 +233,12 @@ :aggregation [[:count]]} :type :query :database db-id}} - :model/Action {action-id :id action-eid :entity_id} {:name "Some action" :type :query :model_id model-id} - :model/Dashboard {dash-id :id dash-eid :entity_id} @@ -271,7 +246,6 @@ :collection_id coll-id :creator_id mark-id :parameters []} - :model/Dashboard {other-dash-id :id other-dash :entity_id} @@ -279,7 +253,6 @@ :collection_id dave-coll-id :creator_id mark-id :parameters []} - :model/Dashboard {param-dash-id :id param-dash :entity_id} @@ -293,7 +266,6 @@ ;; card_id is in a different collection with dashboard's collection :values_source_config {:card_id c1-id :value_field [:field field-id nil]}}]} - :model/DashboardCard _ {:card_id c1-id @@ -304,7 +276,6 @@ :card_id c1-id :target [:dimension [:field field-id {:source-field field2-id}]]}]} - :model/DashboardCard _ {:card_id c2-id @@ -327,12 +298,10 @@ :enabled true}] :column_settings {(str "[\"ref\",[\"field\"," field2-id ",null]]") {:column_title "Locus"}}}} - :model/DashboardCard _ {:action_id action-id :dashboard_id other-dash-id}] - (testing "table and database are extracted as [db schema table] triples" (let [ser (serdes/extract-one "Card" {} (t2/select-one :model/Card :id c1-id))] (is (=? {:serdes/meta [{:model "Card" :id c1-eid :label "some_question"}] @@ -347,7 +316,6 @@ :created_at string?} ser)) (is (not (contains? ser :id))) - (testing "cards depend on their Collection, and anything referenced in the query (including :database)" (is (= #{[{:model "Database" :id "My Database"}] [{:model "Database" :id "My Database"} @@ -357,7 +325,6 @@ {:model "Field" :id "Some Field"}] [{:model "Collection" :id coll-eid}]} (set (serdes/dependencies ser)))))) - (let [ser (serdes/extract-one "Card" {} (t2/select-one :model/Card :id c2-id))] (is (=? {:serdes/meta [{:model "Card" :id c2-eid :label "second_question"}] :creator_id "mark@direstrai.ts" @@ -372,7 +339,6 @@ (is (not (contains? ser :id))) (is (not (contains? ser :table_id)) "table_id always skipped for cards — re-derived on import") (is (contains? ser :database_id) "database_id kept when query is empty") - (testing "cards depend on their Database (kept because query is empty), Collection, and parameter_mappings refs" (is (= #{[{:model "Database" :id "My Database"}] [{:model "Collection" :id coll-eid}] @@ -385,7 +351,6 @@ {:model "Table" :id "Schema'd Table"} {:model "Field" :id "Other Field"}]} (set (serdes/dependencies ser)))))) - (let [ser (serdes/extract-one "Card" {} (t2/select-one :model/Card :id c3-id))] (is (=? {:serdes/meta [{:model "Card" :id c3-eid :label "third_question"}] :creator_id "mark@direstrai.ts" @@ -415,7 +380,6 @@ :created_at string?} ser)) (is (not (contains? ser :id))) - (testing "cards depend on their Database (kept, query empty), Collection, and visualization_settings refs" (is (= #{[{:model "Database" :id "My Database"}] [{:model "Collection" :id coll-eid}] @@ -427,7 +391,6 @@ {:model "Table" :id "Schema'd Table"} {:model "Field" :id "Other Field"}]} (set (serdes/dependencies ser))))))) - (testing "Cards can be based on other cards" (let [ser (serdes/extract-one "Card" {} (t2/select-one :model/Card :id c5-id))] (is (=? {:serdes/meta [{:model "Card" :id c5-eid :label "dependent_question"}] @@ -441,13 +404,11 @@ (is (not (contains? ser :id))) (is (not (contains? ser :table_id)) "table_id stripped") (is (not (contains? ser :database_id)) "database_id stripped — derivable from query") - (testing "and depend on their Collection, Database (from query), and the upstream Card" (is (= #{[{:model "Database" :id "My Database"}] [{:model "Collection" :id coll-eid}] [{:model "Card" :id c4-eid}]} (set (serdes/dependencies ser))))))) - (testing "Dashboards include their Dashcards" (let [ser (ts/extract-one "Dashboard" other-dash-id)] (is (=? {:serdes/meta [{:model "Dashboard" :id other-dash :label "dave_s_dash"}] @@ -475,7 +436,6 @@ :created_at string?} ser)) (is (not (contains? ser :id))) - (testing "and depend on all referenced cards and actions, including those in visualization_settings" (is (= #{[{:model "Card" :id c2-eid}] [{:model "Action" :id action-eid}] @@ -488,7 +448,6 @@ {:model "Field" :id "Other Field"}] [{:model "Collection" :id dave-coll-eid}]} (set (serdes/dependencies ser))))))) - (testing "Dashboards with parameters where the source is a card" (let [ser (ts/extract-one "Dashboard" param-dash-id)] (is (=? {:parameters @@ -507,7 +466,6 @@ {:model "Table", :id "Schemaless Table"} {:model "Field", :id "Some Field"}]} (set (serdes/dependencies ser)))))) - (testing "Cards with parameters where the source is a card" (let [ser (ts/extract-one "Dashboard" param-dash-id)] (is (=? {:parameters @@ -526,7 +484,6 @@ {:model "Table", :id "Schemaless Table"} {:model "Field", :id "Some Field"}]} (set (serdes/dependencies ser)))))) - (testing "collection filtering based on :user option" (testing "only unowned collections are returned with no user" (is (= ["Some Collection"] @@ -542,7 +499,6 @@ (->> {:collection-set (#'extract/collection-set-for-user dave-id)} (serdes/extract-all "Collection") (ids-by-model "Collection")))))) - (testing "dashboards are filtered based on :user" (testing "dashboards in unowned collections are always returned" (is (= #{dash-eid} @@ -581,7 +537,6 @@ {:card_id c3-eid :position 1}])} {:entity_id dc2-eid}]} ser)) - (testing "and depend on all referenced cards, including cards from dashboard cards' series" (is (= #{[{:model "Card" :id c1-eid}] [{:model "Card" :id c2-eid}] @@ -631,16 +586,13 @@ [:human_readable_field_id {:optional true} [:maybe [:sequential [:maybe :string]]]]]]]] ser)) (is (not (contains? ser :id))) - (testing "As of #27062 a Field can only have one Dimension. For historic reasons it comes back as a list" (is (= [dim1-eid] (->> ser :dimensions (map :entity_id))))) - (testing "which depend on just the table" (is (= #{[{:model "Database" :id "My Database"} {:model "Table" :id "Schemaless Table"}]} (set (serdes/dependencies ser))))))) - (testing "foreign key dimensions are inlined into their Fields" (let [ser (ts/extract-one "Field" fk-id)] (is (malli= [:map @@ -656,12 +608,10 @@ [:created_at :string]]]]] ser)) (is (not (contains? ser :id))) - (testing "dimensions are properly inlined" (is (=? [{:human_readable_field_id ["My Database" "PUBLIC" "Customers" "name"] :created_at string?}] (:dimensions ser)))) - (testing "which depend on the Table and both real and human-readable foreign Fields" (is (= #{[{:model "Database" :id "My Database"} {:model "Schema" :id "PUBLIC"} @@ -691,7 +641,6 @@ s1-eid :entity_id} {:name "Snippet 1" :collection_id coll-id :creator_id ann-id} - :model/NativeQuerySnippet {s2-id :id s2-eid :entity_id} {:name "Snippet 2" :collection_id nil @@ -720,15 +669,12 @@ :created_at string?} ser)) (is (not (contains? ser :id))) - (testing "and depend on the Collection" (is (= #{[{:model "Collection" :id coll-eid}]} (set (serdes/dependencies ser))))) - (testing "and will bring collection to extraction" (is (= {["Collection" coll-id] {"NativeQuerySnippet" s1-id}} (serdes/required "NativeQuerySnippet" s1-id)))))) - (testing "or can be outside collections" (let [ser (serdes/extract-one "NativeQuerySnippet" {} (t2/select-one :model/NativeQuerySnippet :id s2-id))] (is (malli= [:map @@ -740,10 +686,8 @@ [:collection_id {:optional true} :nil]] ser)) (is (not (contains? ser :id))) - (testing "and has no deps" (is (empty? (serdes/dependencies ser)))))) - (testing "Snippet collection is exported when snippet is exported as a card dep (#51901)" (is (= {["Collection" coll2-id] nil ["Card" card-id] {"Collection" coll2-id} @@ -774,7 +718,6 @@ (is (contains? targets-with-skip ["Card" card-in-active-id])) (is (not (contains? targets-with-skip ["Card" card-in-archived-id])) "cards in archived collections should not be included"))) - (testing "all collections and cards are included when skip-archived: false" (let [targets-without-skip (#'extract/resolve-targets {:targets [["Collection" parent-id]] :skip-archived false} nil)] @@ -800,7 +743,6 @@ card-ids (into #{} (map (comp :id last :serdes/meta)) (by-model "Card" extraction))] (is (contains? card-ids (:entity_id (t2/select-one :model/Card :id active-card-id)))) (is (not (contains? card-ids (:entity_id (t2/select-one :model/Card :id archived-card-id))))))) - (testing "archived cards are included in extraction with skip-archived: false" (let [extraction (extract/extract {:targets [["Collection" coll-id]] :skip-archived false}) @@ -815,27 +757,23 @@ {:first_name "Ann" :last_name "Wilson" :email "ann@heart.band"} - :model/Collection {coll-id :id coll-eid :entity_id} {:name "Shared Collection" :personal_owner_id nil} - :model/Timeline {empty-id :id empty-eid :entity_id} {:name "Empty Timeline" :collection_id coll-id :creator_id ann-id} - :model/Timeline {line-id :id line-eid :entity_id} {:name "Populated Timeline" :collection_id coll-id :creator_id ann-id} - :model/TimelineEvent _ {:name "First Event" @@ -851,11 +789,9 @@ :created_at string?} ser)) (is (not (contains? ser :id))) - (testing "depend on the Collection" (is (= #{[{:model "Collection" :id coll-eid}]} (set (serdes/dependencies ser))))))) - (testing "with events" (let [ser (ts/extract-one "Timeline" line-id)] (is (=? {:serdes/meta [{:model "Timeline" :id line-eid :label "populated_timeline"}] @@ -868,7 +804,6 @@ ser)) (is (not (contains? ser :id))) (is (not (contains? (-> ser :events first) :id))) - (testing "depend on the Collection" (is (= #{[{:model "Collection" :id coll-eid}]} (set (serdes/dependencies ser))))))))))) @@ -881,7 +816,6 @@ :model/Database {db-id :id} {:name "My Database"} :model/Table {no-schema-id :id} {:name "Schemaless Table" :db_id db-id} :model/Field {field-id :id} {:name "Some Field" :table_id no-schema-id} - :model/Segment {s1-id :id s1-eid :entity_id} @@ -1077,7 +1011,6 @@ (let [ser (ts/extract-one "Table" table-id)] (testing "is_published is omitted (default false)" (is (not (contains? ser :is_published)))) - (testing "collection_id is nil" (is (nil? (:collection_id ser))))))))) @@ -1096,7 +1029,6 @@ :query_type :native :dataset_query (mt/native-query {:query "select 1"}) :creator_id ann-id} - {:keys [action-id]} {:name "My Action" :type :implicit @@ -1133,7 +1065,6 @@ :query_type :native :dataset_query (mt/native-query {:query "select 1"}) :creator_id ann-id} - {:keys [action-id]} {:name "My Action" :type :query @@ -1157,7 +1088,6 @@ :model_id card-eid-1} ser)) (is (not (contains? ser :id))) - (testing "depends on the Model and Database" (is (= #{[{:model "Database" :id "My Database"}] [{:model "Card" :id card-eid-1}]} @@ -1175,7 +1105,6 @@ :percent-email 0.0 :percent-state 0.0 :average-length 8.333333333333334}}}} - :model/FieldValues {fv-id :id values :values} @@ -1199,7 +1128,6 @@ (is (not (contains? ser :id))) (is (not (contains? ser :field_id)) ":field_id is dropped; its implied by the path") - (testing "depend on the parent Field" (is (= #{[{:model "Database" :id "My Database"} {:model "Table" :id "Schemaless Table"} @@ -1220,7 +1148,6 @@ (ts/with-temp-dpc [:model/Database {db-id :id} {:name "My Database"} :model/Table {no-schema-id :id} {:name "Schemaless Table" :db_id db-id} :model/Field {field-id :id} {:name "Some Field" :table_id no-schema-id} - :model/FieldUserSettings {description :description} {:field_id field-id :description "Some custom Description"}] @@ -1235,7 +1162,6 @@ ser)) (is (not (contains? ser :field_id)) ":field_id is dropped; its implied by the path") - (testing "depend on the parent Field" (is (= #{[{:model "Database" :id "My Database"} {:model "Table" :id "Schemaless Table"} @@ -1257,12 +1183,10 @@ :model/Table {table-id :id} {:name "Schemaless Table" :db_id db-id} :model/Field {field-id :id} {:name "A Field" :table_id table-id} :model/Collection {coll-id-1 :id} {:name "1st collection"} - :model/Collection {coll-id-2 :id coll-eid-2 :entity_id} {:name "2nd collection"} - :model/Card {card-id-1 :id card-eid-1 :entity_id} @@ -1271,7 +1195,6 @@ :table_id table-id :collection_id coll-id-1 :creator_id mark-id} - :model/Card {card-id-2 :id} {:name "Card 2" @@ -1321,7 +1244,6 @@ :model/Collection {coll3-id :id coll3-eid :entity_id} {:name "Grandchild Collection" :location (str "/" coll1-id "/" coll2-id "/")} - :model/Database {db-id :id} {:name "My Database"} :model/Table {no-schema-id :id} {:name "Schemaless Table" :db_id db-id} :model/Field _ {:name "Some Field" :table_id no-schema-id} @@ -1331,7 +1253,6 @@ :model/Field {field-id :id} {:name "Other Field" :table_id schema-id} :model/Field {field-id2 :id} {:name "Field To Click 1" :table_id schema-id} :model/Field {field-id3 :id} {:name "Field To Click 2" :table_id schema-id} - ;; One dashboard and three cards in each of the three collections: ;; Two cards contained in the dashboard and one freestanding. :model/Dashboard {dash1-id :id @@ -1355,12 +1276,10 @@ :table_id schema-id :collection_id coll1-id :creator_id mark-id} - :model/DashboardCard _ {:card_id c1-1-id :dashboard_id dash1-id} :model/DashboardCard _ {:card_id c1-2-id :dashboard_id dash1-id} - ;; Second dashboard, in the middle collection. :model/Dashboard {dash2-id :id dash2-eid :entity_id} {:name "Dashboard 2" @@ -1383,18 +1302,15 @@ :table_id schema-id :collection_id coll2-id :creator_id mark-id} - :model/DashboardCard _ {:card_id c2-1-id :dashboard_id dash2-id} :model/DashboardCard _ {:card_id c2-2-id :dashboard_id dash2-id} - ;; Third dashboard, in the grandchild collection. :model/Dashboard {dash3-id :id dash3-eid :entity_id} {:name "Dashboard 3" :collection_id coll3-id :creator_id mark-id} - :model/Card {c3-1-id :id c3-1-eid :entity_id} {:name "Question 3-1" :database_id db-id @@ -1412,12 +1328,10 @@ :table_id schema-id :collection_id coll3-id :creator_id mark-id} - :model/DashboardCard _ {:card_id c3-1-id :dashboard_id dash3-id} :model/DashboardCard _ {:card_id c3-2-id :dashboard_id dash3-id} - ;; Fourth dashboard where its parameter's source is another card :model/Collection {coll4-id :id _coll4-eid :entity_id} {:name "Forth collection"} @@ -1434,7 +1348,6 @@ ;; card_id is in a different collection with dashboard's collection :values_source_config {:card_id c1-1-id :value_field [:field field-id nil]}}]} - :model/Dashboard {dash4-id :id dash4-eid :entity_id} {:name "Dashboard 4" :collection_id coll4-id @@ -1448,7 +1361,6 @@ :value_field [:field field-id nil]}}]} :model/DashboardCard _ {:card_id c4-id :dashboard_id dash4-id} - ;; Fifth dashboard which has :click_behavior defined. :model/Collection {coll5-id :id} {:name "Fifth collection"} :model/Dashboard {clickdash-id :id @@ -1507,13 +1419,11 @@ :target {:type "dimension" :id mapping-id :dimension dimension}}}}})}}}] - (testing "selecting a collection includes settings metabot and data model by default" (is (= #{"Card" "Collection" "Dashboard" "Database" "PythonLibrary" "Setting" "TransformTag" "TransformJob"} (->> (extract/extract {:targets [["Collection" coll1-id]]}) (map (comp :model first serdes/path)) set)))) - (testing "selecting a dashboard gets all cards its dashcards depend on" (testing "grandparent dashboard" (is (= #{[{:model "Dashboard" :id dash1-eid :label "dashboard_1"}] @@ -1522,7 +1432,6 @@ (->> (extract/extract {:targets [["Dashboard" dash1-id]] :no-settings true :no-data-model true :no-transforms true}) (map serdes/path) set)))) - (testing "middle dashboard" (is (= #{[{:model "Dashboard" :id dash2-eid :label "dashboard_2"}] [{:model "Card" :id c2-1-eid :label "question_2_1"}] @@ -1530,7 +1439,6 @@ (->> (extract/extract {:targets [["Dashboard" dash2-id]] :no-settings true :no-data-model true :no-transforms true}) (map serdes/path) set)))) - (testing "grandchild dashboard" (is (= #{[{:model "Dashboard" :id dash3-eid :label "dashboard_3"}] [{:model "Card" :id c3-1-eid :label "question_3_1"}] @@ -1538,7 +1446,6 @@ (->> (extract/extract {:targets [["Dashboard" dash3-id]] :no-settings true :no-data-model true :no-transforms true}) (map serdes/path) set)))) - (testing "a dashboard that has parameter source is another card" (is (=? #{[{:model "Dashboard" :id dash4-eid :label "dashboard_4"}] [{:model "Card" :id c4-eid :label "question_4_1"}] @@ -1549,7 +1456,6 @@ (->> (extract/extract {:targets [["Dashboard" dash4-id]] :no-settings true :no-data-model true :no-transforms true}) (map serdes/path) set))))) - (testing "selecting a dashboard gets any dashboards or cards it links to when clicked" (is (=? #{[{:model "Dashboard" :id clickdash-eid :label "dashboard_with_click_behavior"}] [{:model "Card" :id c3-1-eid :label "question_3_1"}] ; Visualized card @@ -1561,7 +1467,6 @@ (->> (extract/extract {:targets [["Dashboard" clickdash-id]] :no-settings true :no-data-model true :no-transforms true}) (map serdes/path) set)))) - (testing "selecting a collection gets all its contents" (let [grandchild-paths #{[{:model "Collection" :id coll1-eid :label "some_collection"}] [{:model "Collection" :id coll2-eid :label "nested_collection"}] @@ -1596,7 +1501,6 @@ (->> (extract/extract {:targets [["Collection" coll1-id]] :no-settings true :no-data-model true :no-transforms true}) (map serdes/path) set)))) - (testing "depending on data from personal collections results in errors" (mt/with-log-messages-for-level [messages [metabase-enterprise :warn]] (extract/extract {:targets [["Collection" coll4-id]] :no-settings true :no-data-model true :no-transforms true}) @@ -1621,18 +1525,14 @@ :schema "PUBLIC"} :model/Field {field-id :id} {:name "Other Field" :table_id schema-id} :model/Field {field-id3 :id} {:name "Field To Click 2" :table_id schema-id} - :model/Card {card-id :id card-eid :entity_id} {:name "A Normal Question" :database_id db-id :table_id no-schema-id :collection_id coll-id :creator_id mark-id} - :model/Card {deleted-card-id :id} {:collection_id coll-id} - :model/Dashboard {deleted-dash-id :id} {:collection_id coll-id} - :model/Dashboard {clickdash-id :id clickdash-eid :entity_id} {:name "Dashboard" :collection_id coll-id @@ -1660,7 +1560,6 @@ {:type "link" :linkType "question" :targetId deleted-card-id}}}}}] - (t2/delete! :model/Card deleted-card-id) (t2/delete! :model/Dashboard deleted-dash-id) (testing "the references to deleted cards and dashboards are ignored" @@ -1700,11 +1599,9 @@ :model/Field {nested-id :id} {:name "Nested Field" :table_id schema-id :parent_id other-field-id}] - (testing "fields that reference foreign keys are properly exported as Field references" (is (= ["My Database" nil "Schemaless Table" "Some Field"] (:fk_target_field_id (ts/extract-one "Field" fk-id))))) - (testing "Fields that reference parents are properly exported as Field references" (is (= ["My Database" "PUBLIC" "Schema'd Table" "Other Field"] (:parent_id (ts/extract-one "Field" nested-id)))) @@ -2032,21 +1929,17 @@ [:model/Card {model-id :id model-eid :entity_id} {:name "AI Model" :type :model} - :model/Collection {coll-id :id coll-eid :entity_id} {:name "Metabot Collection"} - :model/Metabot {metabot-id :id metabot-eid :entity_id} {:name "Test Metabot" :description "A test metabot" :use_verified_content false :collection_id coll-id} - :model/MetabotPrompt {metabot-prompt-eid :entity_id} {:metabot_id metabot-id :prompt "A sample prompt" :model :model :card_id model-id}] - (testing "metabot extraction" (let [ser (ts/extract-one "Metabot" metabot-id)] (is (=? {:serdes/meta [{:model "Metabot" :id metabot-eid}] @@ -2065,11 +1958,9 @@ ser)) (is (not (contains? ser :id))) (is (not (contains? ser :use_verified_content))) - (testing "metabot depends on its model entities" (is (= #{[{:model "Card" :id model-eid}]} (set (serdes/dependencies ser))))) - (testing "metabot storage-path uses top-level metabots directory" (is (= [{:label "metabots"} {:label "Test Metabot" :key metabot-eid}] (serdes/storage-path ser {}))))))))) @@ -2078,26 +1969,21 @@ (mt/with-empty-h2-app-db! (ts/with-temp-dpc [:model/Collection {model-id :id} {:name "AI Model"} - :model/Card {card-id :id card-eid :entity_id} {:name "AI Model" :type :model :collection_id model-id} - :model/Collection {coll-id :id coll-eid :entity_id} {:name "Metabot Collection"} - :model/Metabot {metabot-id :id metabot-eid :entity_id} {:name "Test Metabot" :description "A test metabot" :use_verified_content false :collection_id coll-id} - :model/MetabotPrompt {metabot-prompt-eid :entity_id} {:metabot_id metabot-id :prompt "A sample prompt" :model :model :card_id card-id}] - (testing "metabot extraction" (let [ser (ts/extract-one "Metabot" metabot-id)] (is (=? {:serdes/meta [{:model "Metabot" :id metabot-eid}] @@ -2116,7 +2002,6 @@ ser)) (is (not (contains? ser :id))) (is (not (contains? ser :use_verified_content))) - (testing "metabot depends on its prompts' cards" (is (= #{[{:model "Card" :id card-eid}]} (set (serdes/dependencies ser)))))))))) @@ -2137,7 +2022,6 @@ :model/Card linked-card {:name "Linked Card"} :model/Dashboard dashboard {:name "Smart Linked Dashboard"} :model/Table table {:name "linked_table"}] - (t2/update! :model/Document :id (u/the-id document) {:document {:type "doc" :content [{:type "cardEmbed" :attrs {:id (u/the-id card)}} @@ -2176,7 +2060,6 @@ (is (not (contains? ser :archived))) (is (not (contains? ser :archived_directly))) (is (not (contains? ser :id))) - (testing "depends on its collection, cardEmbeds and smarkLinks " (is (= #{[{:model "Collection" :id (:entity_id collection)}] [{:model "Card" :id (:entity_id card)}] @@ -2208,7 +2091,6 @@ :DIMENSION [(str "$_card:" card-id "_name")]}}} result (serdes/import-visualizer-settings input)] (is (= expected result)))) - (testing "transforms sourceId in column mappings" (let [input {:visualization {:columnValuesMapping @@ -2245,17 +2127,14 @@ {hourly-tag-id :id hourly-tag-eid :entity_id} {:name "hourly" :built_in_type "hourly"} - :model/TransformTag {daily-tag-eid :entity_id} {:name "daily" :built_in_type "daily"} - ;; Create custom tag :model/TransformTag {custom-tag-id :id custom-tag-eid :entity_id} {:name "custom-etl"}] - (testing "built-in tags extract correctly" (let [ser (serdes/extract-one "TransformTag" {} (t2/hydrate (t2/select-one :model/TransformTag :id hourly-tag-id) :tags))] (is (=? {:serdes/meta [{:model "TransformTag" @@ -2266,7 +2145,6 @@ ser)) (is (not (contains? ser :id))) (is (empty? (serdes/dependencies ser))))) - (testing "custom tags extract correctly" (let [ser (serdes/extract-one "TransformTag" {} (t2/hydrate (t2/select-one :model/TransformTag :id custom-tag-id) :tags))] (is (=? {:serdes/meta [{:model "TransformTag" @@ -2277,7 +2155,6 @@ (is (not (contains? ser :built_in_type))) (is (not (contains? ser :id))) (is (empty? (serdes/dependencies ser))))) - (testing "all transform tags are extracted" (is (= #{hourly-tag-eid daily-tag-eid custom-tag-eid} (ids-by-model "TransformTag" (extract/extract {})))))))))) @@ -2291,36 +2168,29 @@ (ts/with-temp-dpc [:model/Database {db-id :id} {:name "My Database"} - :model/Table {table-id :id} {:name "Schemaless Table" :db_id db-id} - :model/Field {_field-id :id} {:name "Some Field" :table_id table-id} - :model/Collection {coll-id :id coll-eid :entity_id} {:name "Transform Collection" :namespace :transforms} - ;; Create tags for associations :model/TransformTag {hourly-tag-id :id hourly-tag-eid :entity_id} {:name "hourly" :built_in_type "hourly" :entity_id "hourlyhourlyhourlyxxx"} - :model/TransformTag {daily-tag-id :id daily-tag-eid :entity_id} {:name "daily" :built_in_type "daily" :entity_id "dailydailydailydailyx"} - :model/TransformTag {custom-tag-id :id custom-tag-eid :entity_id} {:name "custom-etl" :entity_id "custometlcustometlcus"} - ;; Create Transform :model/Transform {transform-id :id @@ -2337,7 +2207,6 @@ :type "table" :schema "public" :name "target_table"}} - ;; Python transform with source-tables :model/Transform {python-transform-id :id @@ -2356,26 +2225,22 @@ :type "table" :schema "public" :name "target_table"}} - ;; Create tag associations with specific positions :model/TransformTransformTag {} {:transform_id transform-id :tag_id hourly-tag-id :position 0} - :model/TransformTransformTag {} {:transform_id transform-id :tag_id custom-tag-id :position 1} - :model/TransformTransformTag {} {:transform_id transform-id :tag_id daily-tag-id :position 2}] - (let [ser (serdes/extract-one "Transform" {} (t2/hydrate (t2/select-one :model/Transform :id transform-id) :tags))] (testing "basic Transform structure" (is (=? {:serdes/meta [{:model "Transform" @@ -2385,21 +2250,18 @@ :created_at string?} ser)) (is (not (contains? ser :id)))) - (testing "source and target MBQL export" (is (=? {:source {:query {:database "My Database" :lib/type :mbql/query :stages [{:source-table ["My Database" nil "Schemaless Table"]}]}} :target {:database "My Database" :type "table" :schema "public" :name "target_table"}} (select-keys ser [:source :target])))) - (testing "tag associations with preserved order" (is (= 3 (count (:tags ser)))) (let [tag-ids (map :tag_id (:tags ser)) positions (map :position (:tags ser))] (is (= [hourly-tag-eid custom-tag-eid daily-tag-eid] tag-ids)) (is (= [0 1 2] positions)))) - (testing "dependencies include collection, source table, and tags" (let [deps (set (serdes/dependencies ser))] (is (contains? deps [{:model "Collection" :id coll-eid}])) @@ -2408,7 +2270,6 @@ (is (contains? deps [{:model "TransformTag" :id hourly-tag-eid}])) (is (contains? deps [{:model "TransformTag" :id custom-tag-eid}])) (is (contains? deps [{:model "TransformTag" :id daily-tag-eid}]))))) - (testing "python transform source-tables export" (let [ser (serdes/extract-one "Transform" {} (t2/hydrate (t2/select-one :model/Transform :id python-transform-id) :tags))] (is (=? {:source {:type :python @@ -2420,7 +2281,6 @@ :table "Schemaless Table"}] :body "df = ctx.source.orders"}} ser)))) - (testing "transforms are extracted" (is (= #{transform-eid python-transform-eid} (ids-by-model "Transform" (extract/extract {})))))))))) @@ -2432,7 +2292,6 @@ (ts/with-temp-dpc [:model/Database {db-id :id} {:name "My Database"} - :model/Transform {transform-id :id transform-eid :entity_id} @@ -2470,17 +2329,14 @@ {hourly-tag-id :id hourly-tag-eid :entity_id} {:name "hourly" :built_in_type "hourly"} - :model/TransformTag {daily-tag-id :id daily-tag-eid :entity_id} {:name "daily" :built_in_type "daily"} - :model/TransformTag {custom-tag-id :id custom-tag-eid :entity_id} {:name "custom-etl"} - ;; Create built-in TransformJob :model/TransformJob {hourly-job-id :id @@ -2489,7 +2345,6 @@ :description "Executes transforms tagged with 'hourly' every hour" :schedule "0 0 * * * ? *" :built_in_type "hourly"} - ;; Create custom TransformJob :model/TransformJob {custom-job-id :id @@ -2497,26 +2352,22 @@ {:name "Custom ETL Job" :description "Custom data processing job" :schedule "0 0 2 * * ? *"} - ;; Create job-tag associations :model/TransformJobTransformTag {} {:job_id hourly-job-id :tag_id hourly-tag-id :position 0} - :model/TransformJobTransformTag {} {:job_id custom-job-id :tag_id custom-tag-id :position 0} - :model/TransformJobTransformTag {} {:job_id custom-job-id :tag_id daily-tag-id :position 1}] - (testing "built-in job extracts correctly" (let [ser (serdes/extract-one "TransformJob" {} (t2/hydrate (t2/select-one :model/TransformJob :id hourly-job-id) :job_tags))] (is (=? {:serdes/meta [{:model "TransformJob" @@ -2535,7 +2386,6 @@ (testing "dependencies include referenced tags" (is (= #{[{:model "TransformTag" :id hourly-tag-eid}]} (set (serdes/dependencies ser))))))) - (testing "custom job extracts correctly" (let [ser (serdes/extract-one "TransformJob" {} (t2/hydrate (t2/select-one :model/TransformJob :id custom-job-id) :job_tags))] (is (=? {:serdes/meta [{:model "TransformJob" @@ -2557,7 +2407,6 @@ (is (= #{[{:model "TransformTag" :id custom-tag-eid}] [{:model "TransformTag" :id daily-tag-eid}]} (set (serdes/dependencies ser))))))) - (testing "all transform jobs are extracted" (is (= #{hourly-job-eid custom-job-eid} (ids-by-model "TransformJob" (extract/extract {})))))))))) @@ -2658,7 +2507,6 @@ :created_at string?} ser)) (is (not (contains? ser :id))) - (testing "has no dependencies" (is (empty? (serdes/dependencies ser))))))))) @@ -2786,7 +2634,6 @@ ser)) (is (not (contains? ser :id))) (is (not (contains? ser :active))))) - (testing "http channel extraction" (let [ser (ts/extract-one "Channel" http-id)] (is (=? {:serdes/meta [{:model "Channel" :id "HTTP Channel"}] @@ -2798,7 +2645,6 @@ :auth-method "none"} :created_at string?} ser)))) - (testing "channel storage-path uses top-level channels directory" (let [ser (ts/extract-one "Channel" email-id)] (is (= [{:label "channels"} {:label "Email Channel" :key "Email Channel"}] @@ -2914,7 +2760,6 @@ light-eid :entity_id} {:name "Light" :settings {}} - :model/EmbeddingTheme {_dark-id :id dark-eid :entity_id} @@ -2934,7 +2779,6 @@ (is (not (contains? ser :id))) (testing "embedding themes have no dependencies" (is (empty? (serdes/dependencies ser)))))) - (testing "all embedding themes are extracted" (is (= #{light-eid dark-eid} (ids-by-model "EmbeddingTheme" (extract/extract {})))))))) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/ingest_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/ingest_test.clj index 2c36e5401b7e..5837ef28e22b 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/ingest_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/ingest_test.clj @@ -10,7 +10,6 @@ (ts/with-random-dump-dir [dump-dir "serdesv2-"] (io/make-parents dump-dir "collections" "1234567890abcdefABCDE_the_label" "fake") ; Prepare the right directories. (io/make-parents dump-dir "collections" "0987654321zyxwvuABCDE" "fake") - (spit (io/file dump-dir "settings.yaml") (yaml/generate-string {:some-key "with string value" :another-key 7 @@ -21,7 +20,6 @@ :label "the_label"}]})) (spit (io/file dump-dir "collections" "0987654321zyxwvuABCDE" "0987654321zyxwvuABCDE.yaml") (yaml/generate-string {:some "other" :data "in this one" :entity_id "0987654321zyxwvuABCDE" :serdes/meta [{:model "Collection" :id "0987654321zyxwvuABCDE"}]})) - (let [ingestable (ingest/ingest-yaml dump-dir) exp-files {[{:model "Collection" :id "1234567890abcdefABCDE" @@ -38,7 +36,6 @@ (testing "the right set of files is returned by ingest-list" (is (= (set (map #'ingest/strip-labels (keys exp-files))) (set (ingest/ingest-list ingestable))))) - (testing "individual reads in any order are correct" (doseq [abs-path (->> exp-files keys @@ -56,13 +53,11 @@ (spit (io/file dump-dir "collections" "1234567890abcdefABCDE_human-readable-things" "1234567890abcdefABCDE_human-readable-things.yaml") (yaml/generate-string {:some "made up" :data "here" :serdes/meta [{:model "Collection" :id "1234567890abcdefABCDE" :label "human-readable-things"}]})) - (let [ingestable (ingest/ingest-yaml dump-dir) exp {:some "made up" :data "here" :serdes/meta [{:model "Collection" :id "1234567890abcdefABCDE" :label "human-readable-things"}]}] (testing "the returned set of abstract paths does not contain labels" (is (= #{[{:model "Collection" :id "1234567890abcdefABCDE"}]} (into #{} (ingest/ingest-list ingestable))))) - (testing "fetching the file with the label works" (is (= exp (ingest/ingest-one ingestable [{:model "Collection" :id "1234567890abcdefABCDE" :label "human-readable-things"}])))) @@ -77,7 +72,6 @@ (yaml/generate-string {:visualization_settings {:column_settings {"[\"name\",\"sum\"]" {:number_style "currency"}}} :serdes/meta [{:model "Card" :id "1234567890abcdefABCDE"}]})) - (let [ingestable (ingest/ingest-yaml dump-dir) exp {:visualization_settings {:column_settings {"[\"name\",\"sum\"]" {:number_style "currency"}}} :serdes/meta [{:model "Card" :id "1234567890abcdefABCDE"}]}] diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj index 13f4e6572b22..145ff342e4fc 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/load_test.clj @@ -71,7 +71,6 @@ (is (some (fn [{[{:keys [model id]}] :serdes/meta}] (and (= model "Collection") (= id eid1))) @serialized)))) - (testing "loading into an empty database succeeds" (ts/with-db dest-db (serdes.load/load-metabase! (ingestion-in-memory @serialized)) @@ -80,7 +79,6 @@ (is (= 1 (count colls))) (is (= "Basic Collection" (:name (first colls)))) (is (= eid1 (:entity_id (first colls))))))) - (testing "loading again into the same database does not duplicate" (ts/with-db dest-db (serdes.load/load-metabase! (ingestion-in-memory @serialized)) @@ -106,7 +104,6 @@ :name "Grandchild Collection" :location (format "/%d/%d/" (:id @parent) (:id @child)))) (reset! serialized (into [] (serdes.extract/extract {}))))) - (testing "deserialization into a database that already has the parent, but with a different ID" (ts/with-db dest-db (ts/create! :model/Collection :name "Unrelated Collection") @@ -150,38 +147,32 @@ (reset! f2s (ts/create! :model/Field :name "Foreign Key" :table_id (:id @t2s) :fk_target_field_id (:id @f1s))) (reset! f3s (ts/create! :model/Field :name "Nested Field" :table_id (:id @t1s) :parent_id (:id @f1s))) (reset! serialized (into [] (serdes.extract/extract {}))))) - (testing "serialization of databases is based on the :name" (is (= #{(:name @db1s) (:name @db2s) "test-data (h2)"} ; TODO I'm not sure where the `test-data` one comes from. (ids-by-model @serialized "Database")))) - (testing "tables reference their databases by name" (is (= #{(:name @db1s) (:name @db2s) "test-data (h2)"} (->> @serialized (filter #(-> % :serdes/meta last :model (= "Table"))) (map :db_id) set)))) - (testing "foreign key references are serialized as a field path" (is (= ["db1" nil "posts" "Target Field"] (->> @serialized (u/seek #(and (-> % :serdes/meta last :model (= "Field")) (-> % :name (= "Foreign Key")))) :fk_target_field_id)))) - (testing "Parent field references are serialized as a field path" (is (= ["db1" nil "posts" "Target Field"] (->> @serialized (u/seek #(and (-> % :serdes/meta last :model (= "Field")) (-> % :name (= "Nested Field")))) :parent_id)))) - (testing "deserialization works properly, keeping the same-named tables apart" (ts/with-db dest-db (serdes.load/load-metabase! (ingestion-in-memory @serialized)) (reset! db1d (t2/select-one :model/Database :name (:name @db1s))) (reset! db2d (t2/select-one :model/Database :name (:name @db2s))) - (is (= 3 (t2/count :model/Database))) (is (every? #(= "complete" (:initial_sync_status %)) (t2/select :model/Database))) (is (= #{"db1" "db2" "test-data (h2)"} @@ -216,7 +207,6 @@ db2d (atom nil) table2d (atom nil) field2d (atom nil)] - (ts/with-dbs [source-db dest-db] (testing "serializing the original database, table, field and card" (ts/with-db source-db @@ -239,7 +229,6 @@ :database (:id @db1s)} :display :line)) (reset! serialized (into [] (serdes.extract/extract {}))))) - (testing "the serialized form is as desired" (let [card (first (by-model @serialized "Card"))] (is (=? {:lib/type :mbql/query @@ -248,7 +237,6 @@ :aggregation [[:count {}]]}] :database "my-db"} (:dataset_query card))))) - (testing "deserializing adjusts the IDs properly" (ts/with-db dest-db ;; A different database and tables, so the IDs don't match. @@ -256,21 +244,17 @@ (reset! table2d (ts/create! :model/Table :name "orders" :db_id (:id @db2d))) (reset! field2d (ts/create! :model/Field :name "subtotal" :table_id (:id @table2d))) (reset! user1d (ts/create! :model/User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on")) - ;; Load the serialized content. (serdes.load/load-metabase! (ingestion-in-memory @serialized)) - ;; Fetch the relevant bits (reset! db1d (t2/select-one :model/Database :name "my-db")) (reset! table1d (t2/select-one :model/Table :name "customers")) (reset! field1d (t2/select-one :model/Field :table_id (:id @table1d) :name "age")) (reset! card1d (t2/select-one :model/Card :name "Example Card")) - (testing "the main Database, Table, and Field have different IDs now" (is (not= (:id @db1s) (:id @db1d))) (is (not= (:id @table1s) (:id @table1d))) (is (not= (:id @field1s) (:id @field1d)))) - (is (not= (:dataset_query @card1s) (:dataset_query @card1d))) (testing "the Card's query is based on the new Database, Table, and Field IDs" @@ -304,7 +288,6 @@ db2d (atom nil) table2d (atom nil) field2d (atom nil)] - (ts/with-dbs [source-db dest-db] (testing "serializing the original database, table, field and card" (ts/with-db source-db @@ -318,7 +301,6 @@ :filter [:< [:field (:id @field1s) nil] 18]} :creator_id (:id @user1s))) (reset! serialized (into [] (serdes.extract/extract {}))))) - (testing "exported form is properly converted" (is (=? {:database "my-db" :stages [{:filters [[:< {} [:field {} ["my-db" nil "customers" "age"]] 18]] @@ -328,7 +310,6 @@ (by-model "Segment") first :definition)))) - (testing "deserializing adjusts the IDs properly" (ts/with-db dest-db ;; A different database and tables, so the IDs don't match. @@ -336,21 +317,17 @@ (reset! table2d (ts/create! :model/Table :name "orders" :db_id (:id @db2d))) (reset! field2d (ts/create! :model/Field :name "subtotal" :table_id (:id @table2d))) (reset! user1d (ts/create! :model/User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on")) - ;; Load the serialized content. (serdes.load/load-metabase! (ingestion-in-memory @serialized)) - ;; Fetch the relevant bits (reset! db1d (t2/select-one :model/Database :name "my-db")) (reset! table1d (t2/select-one :model/Table :name "customers")) (reset! field1d (t2/select-one :model/Field :table_id (:id @table1d) :name "age")) (reset! seg1d (t2/select-one :model/Segment :name "Minors")) - (testing "the main Database, Table, and Field have different IDs now" (is (not= (:id @db1s) (:id @db1d))) (is (not= (:id @table1s) (:id @table1d))) (is (not= (:id @field1s) (:id @field1d)))) - (is (not= (:definition @seg1s) (:definition @seg1d))) (testing "the Segment's definition is based on the new Database, Table, and Field IDs" @@ -524,7 +501,6 @@ field1d (atom nil) seg1d (atom nil) msr1d (atom nil)] - (ts/with-dbs [source-db dest-db] (testing "serializing measure that references a segment" (ts/with-db source-db @@ -547,30 +523,24 @@ :definition measure-definition :creator_id (:id @user1s)))) (reset! serialized (into [] (serdes.extract/extract {}))))) - (testing "exported form has segment reference with entity_id" (let [measure (first (by-model @serialized "Measure"))] (is (=? {:definition {:stages [{:aggregation [[:count-where {} [:segment {} (:entity_id @seg1s)]]]}]}} measure)))) - (testing "deserializing adjusts the segment IDs properly" (ts/with-db dest-db (reset! user1s (ts/create! :model/User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on")) - ;; Load the serialized content (serdes.load/load-metabase! (ingestion-in-memory @serialized)) - ;; Fetch the relevant bits (reset! db1d (t2/select-one :model/Database :name "my-db")) (reset! table1d (t2/select-one :model/Table :name "products")) (reset! field1d (t2/select-one :model/Field :table_id (:id @table1d) :name "price")) (reset! seg1d (t2/select-one :model/Segment :name "Expensive")) (reset! msr1d (t2/select-one :model/Measure :name "Expensive Count")) - (testing "segment and measure were loaded" (is (some? @seg1d)) (is (some? @msr1d))) - (testing "the measure's definition references the segment by new ID" (is (=? {:lib/type :mbql/query :stages [{:source-table (:id @table1d) @@ -612,7 +582,6 @@ db2d (atom nil) table2d (atom nil) field3d (atom nil)] - (ts/with-dbs [source-db dest-db] (testing "serializing the original database, table, field and card" (ts/with-db source-db @@ -691,7 +660,6 @@ :parameter_mappings [{:parameter_id "deadbeef" :card_id (:id @card1s) :target [:dimension [:field (:id @field1s) {:source-field (:id @field2s)}]]}]))) - (reset! serialized (into [] (serdes.extract/extract {}))) (let [card (-> @serialized (by-model "Card") first) dash (-> @serialized (by-model "Dashboard") first)] @@ -705,7 +673,6 @@ :target [:dimension [:field ["my-db" nil "orders" "subtotal"] {:source-field ["my-db" nil "orders" "invoice"]}]]}]}] (:dashcards dash)))) - (testing "exported :visualization_settings are properly converted" (let [exp-card {:table.pivot_column "SOURCE" :table.cell_column "sum" @@ -768,7 +735,6 @@ (:visualization_settings card))) (is (= exp-dashcard (-> dash :dashcards first :visualization_settings)))))))) - (testing "deserializing adjusts the IDs properly" (ts/with-db dest-db ;; A different database and tables, so the IDs don't match. @@ -778,10 +744,8 @@ (ts/create! :model/Field :name "name" :table_id (:id @table2d)) (ts/create! :model/Field :name "address" :table_id (:id @table2d)) (reset! user1d (ts/create! :model/User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on")) - ;; Load the serialized content. (serdes.load/load-metabase! (ingestion-in-memory @serialized)) - ;; Fetch the relevant bits (reset! db1d (t2/select-one :model/Database :name "my-db")) (reset! table1d (t2/select-one :model/Table :name "orders")) @@ -791,13 +755,11 @@ (reset! tab2d (t2/select-one :model/DashboardTab :name "Tab for dash2")) (reset! card1d (t2/select-one :model/Card :name "The Card")) (reset! dashcard1d (t2/select-one :model/DashboardCard :card_id (:id @card1d) :dashboard_id (:id @dash1d))) - (testing "the main Database, Table, and Field have different IDs now" (is (not= (:id @db1s) (:id @db1d))) (is (not= (:id @table1s) (:id @table1d))) (is (not= (:id @field1s) (:id @field1d))) (is (not= (:id @field2s) (:id @field2d)))) - (is (not= (:parameter_mappings @dashcard1s) (:parameter_mappings @dashcard1d))) (is (not= (:parameter_mappings @card1s) @@ -835,7 +797,6 @@ timeline2d (atom nil) eventsT1 (atom nil) eventsT2 (atom nil)] - (ts/with-dbs [source-db dest-db] (testing "serialize correctly" (ts/with-db source-db @@ -854,12 +815,9 @@ (reset! event3s (ts/create! :model/TimelineEvent :name "Different event" :timeline_id (:id @timeline2s) :creator_id (:id @user1s) :timezone "America/New_York" :time_matters true :timestamp (t/offset-date-time 2022 10 31 19 00 00))) - (testing "expecting 3 events" (is (= 3 (t2/count :model/TimelineEvent)))) - (reset! serialized (into [] (serdes.extract/extract {}))) - (let [timelines (by-model @serialized "Timeline") timeline1 (first (filter #(= (:entity_id %) (:entity_id @timeline1s)) timelines)) timeline2 (first (filter #(= (:entity_id %) (:entity_id @timeline2s)) timelines))] @@ -889,7 +847,6 @@ timeline1)) (is (= 2 (-> timeline1 :events count))) (is (= 1 (-> timeline2 :events count))))))) - (testing "deserializing merges events properly" (ts/with-db dest-db ;; The collection, timeline 1 and event 2 already exist. Event 1, plus timeline 2 and its event 3, are new. @@ -901,28 +858,22 @@ (ts/create! :model/TimelineEvent :name "Second thing with different name" :timeline_id (:id @timeline1s) :timestamp (:timestamp @event2s) :creator_id (:id @user1s) :timezone "America/New_York") - ;; Load the serialized content. (serdes.load/load-metabase! (ingestion-in-memory @serialized)) - ;; Fetch the relevant bits (reset! timeline2d (t2/select-one :model/Timeline :entity_id (:entity_id @timeline2s))) (reset! eventsT1 (t2/select :model/TimelineEvent :timeline_id (:id @timeline1d))) (reset! eventsT2 (t2/select :model/TimelineEvent :timeline_id (:id @timeline2d))) - (testing "no duplication - there are two timelines with the right event counts" (is (some? @timeline2d)) (is (= 2 (count @eventsT1))) (is (= 1 (count @eventsT2)))) - (testing "resulting events match up" (let [[event1 event2] (sort-by :timestamp @eventsT1)] (is (= (:timestamp @event1s) (:timestamp event1))) (is (= (:timestamp @event2s) (:timestamp event2))) - (is (= (:timestamp @event3s) (:timestamp (first @eventsT2)))) - (is (= (:name @event2s) (:name event2)) "existing event name should be updated"))))))))) @@ -939,7 +890,6 @@ user1d (atom nil) dash1d (atom nil) dash2d (atom nil)] - (ts/with-dbs [source-db dest-db] (testing "serializing the original entities" (ts/with-db source-db @@ -948,7 +898,6 @@ (reset! dash1s (ts/create! :model/Dashboard :name "My Dashboard" :creator_id (:id @user1s))) (reset! dash2s (ts/create! :model/Dashboard :name "Linked dashboard" :creator_id (:id @user2s))) (reset! serialized (into [] (serdes.extract/extract {}))))) - (testing "deserializing finds the matching user and synthesizes the missing one" (ts/with-db dest-db ;; Create another random user to change the user IDs. @@ -958,18 +907,14 @@ (ts/create! :model/Dashboard :name "Other dashboard B") (ts/create! :model/Dashboard :name "Other dashboard C") (reset! user1d (ts/create! :model/User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on")) - ;; Load the serialized content. (serdes.load/load-metabase! (ingestion-in-memory @serialized)) - (reset! dash1d (t2/select-one :model/Dashboard :name "My Dashboard")) (reset! dash2d (t2/select-one :model/Dashboard :name "Linked dashboard")) - (testing "the Dashboards and Users have different IDs now" (is (not= (:id @dash1s) (:id @dash1d))) (is (not= (:id @dash2s) (:id @dash2d))) (is (not= (:id @user1s) (:id @user1d)))) - (testing "both existing User and the new one are set up properly" (is (= (:id @user1d) (:creator_id @dash1d))) (let [user2d-id (:creator_id @dash2d) @@ -1003,7 +948,6 @@ db2d (atom nil) table2d (atom nil) field3d (atom nil)] - (testing "serializing the original database, table, field and fieldvalues" (mt/with-empty-h2-app-db! (reset! db1s (ts/create! :model/Database :name "my-db")) @@ -1013,9 +957,7 @@ (reset! fv1s (ts/create! :model/FieldValues :field_id (:id @field1s) :values ["AZ" "CA" "NY" "TX"])) (reset! fv2s (ts/create! :model/FieldValues :field_id (:id @field2s) :values ["CONSTRUCTION" "DAYLIGHTING" "DELIVERY" "HAULING"])) - (reset! serialized (into [] (serdes.extract/extract {:include-field-values true}))) - (testing "the expected fields are serialized" (is (= 1 (->> @serialized @@ -1025,7 +967,6 @@ {:model "Table" :id "VENUES"} {:model "Field" :id "NAME"}])) count)))) - (testing "FieldValues are serialized under their fields, with their own ID always 0" (let [fvs (by-model @serialized "FieldValues")] (is (= #{[{:model "Database" :id "my-db"} @@ -1040,7 +981,6 @@ (map serdes/path) (filter #(-> % first :id (= "my-db"))) set))))))) - (testing "deserializing finds existing FieldValues properly" (mt/with-empty-h2-app-db! ;; A different database and tables, so the IDs don't match. @@ -1049,7 +989,6 @@ (reset! field3d (ts/create! :model/Field :name "SUBTOTAL" :table_id (:id @table2d))) (ts/create! :model/Field :name "DISCOUNT" :table_id (:id @table2d)) (ts/create! :model/Field :name "UNITS" :table_id (:id @table2d)) - ;; Now the database, table, fields and *one* of the FieldValues from the src side. (reset! db1d (ts/create! :model/Database :name "my-db")) (reset! table1d (ts/create! :model/Table :name "CUSTOMERS" :db_id (:id @db1d))) @@ -1057,24 +996,19 @@ (reset! field2d (ts/create! :model/Field :name "CATEGORY" :table_id (:id @table1d))) ;; The :values are different here; they should get overwritten by the update. (reset! fv1d (ts/create! :model/FieldValues :field_id (:id @field1d) :values ["WA" "NC" "NM" "WI"])) - ;; Load the serialized content. (serdes.load/load-metabase! (ingestion-in-memory @serialized)) - ;; Fetch the relevant bits (reset! fv1d (t2/select-one :model/FieldValues :field_id (:id @field1d))) (reset! fv2d (t2/select-one :model/FieldValues :field_id (:id @field2d))) - (testing "the main Database, Table, and Field have different IDs now" (is (not= (:id @db1s) (:id @db1d))) (is (not= (:id @table1s) (:id @table1d))) (is (not= (:id @field1s) (:id @field1d))) (is (not= (:id @field2s) (:id @field2d)))) - (testing "there are 2 FieldValues defined under fields of table1d" (let [fields (t2/select-pks-set :model/Field :table_id (:id @table1d))] (is (= 2 (t2/count :model/FieldValues :field_id [:in fields]))))) - (testing "existing FieldValues are properly found and updated" (is (= (set (:values @fv1s)) (set (:values @fv1d))))) (testing "new FieldValues are properly added" @@ -1095,7 +1029,6 @@ table1d (atom nil) coll1d (atom nil) table2d (atom nil)] - (ts/with-dbs [source-db dest-db] (testing "serializing published tables" (ts/with-db source-db @@ -1106,7 +1039,6 @@ (reset! table2s (ts/create! :model/Table :name "unpublished_table" :db_id (:id @db1s) :is_published false)) (reset! serialized (into [] (serdes.extract/extract {}))))) - (testing "serialized form has collection entity_id, not DB id" (let [pub-table (->> @serialized (filter #(and (-> % :serdes/meta last :model (= "Table")) @@ -1114,22 +1046,18 @@ first)] (is (true? (:is_published pub-table))) (is (= (:entity_id @coll1s) (:collection_id pub-table))))) - (testing "deserializing restores collection_id correctly" (ts/with-db dest-db ;; Load the serialized content (serdes.load/load-metabase! (ingestion-in-memory @serialized)) - ;; Fetch the relevant bits (reset! db1d (t2/select-one :model/Database :name "my-db")) (reset! coll1d (t2/select-one :model/Collection :name "Publishing Collection")) (reset! table1d (t2/select-one :model/Table :name "published_table" :db_id (:id @db1d))) (reset! table2d (t2/select-one :model/Table :name "unpublished_table" :db_id (:id @db1d))) - (testing "published table has correct is_published and collection_id" (is (true? (:is_published @table1d))) (is (= (:id @coll1d) (:collection_id @table1d)))) - (testing "unpublished table has is_published=false and no collection" (is (false? (:is_published @table2d))) (is (nil? (:collection_id @table2d)))))))))) @@ -1140,14 +1068,12 @@ ;; either location. (let [db1s (atom nil) table1s (atom nil)] - (testing "loading a bare card" (mt/with-empty-h2-app-db! (reset! db1s (ts/create! :model/Database :name "my-db")) (reset! table1s (ts/create! :model/Table :name "CUSTOMERS" :db_id (:id @db1s))) (ts/create! :model/Field :name "STATE" :table_id (:id @table1s)) (ts/create! :model/User :first_name "Geddy" :last_name "Lee" :email "glee@rush.yyz") - (testing "depending on existing values works" (let [ingestion (ingestion-in-memory [{:serdes/meta [{:model "Card" :id "0123456789abcdef_0123"}] :created_at (t/instant) @@ -1162,7 +1088,6 @@ :table_id ["my-db" nil "CUSTOMERS"] :visualization_settings {}}])] (is (some? (serdes.load/load-metabase! ingestion))))) - (testing "depending on nonexisting values fails" (let [ingestion (ingestion-in-memory [{:serdes/meta [{:model "Card" :id "0123456789abcdef_0123"}] :created_at (t/instant) @@ -1204,7 +1129,6 @@ :snippet-id (:id @snippet1s)}} :query "SELECT 1;"}})) (ts/create! :model/User :first_name "Geddy" :last_name "Lee" :email "glee@rush.yyz") - (testing "on extraction" (reset! extracted (serdes/extract-one "Card" {} @card1s)) (is (=? {:stages [{:lib/type :mbql.stage/native @@ -1230,11 +1154,9 @@ id2 (u/generate-nano-id) load! #(serdes.load/load-metabase! (ingestion-in-memory [(serdes/extract-one "NativeQuerySnippet" {} %)]))] - (testing "setup is correct" (is (= (:entity_id snippet) (t2/select-one-fn :entity_id :model/NativeQuerySnippet :name unique-name)))) - (testing "loading snippet with same name will get it renamed" (load! (assoc snippet :entity_id id1)) (testing "old snippet is in place" @@ -1243,12 +1165,10 @@ (testing "new one got new name" (is (= (str unique-name " (copy)") (t2/select-one-fn :name :model/NativeQuerySnippet :entity_id id1))))) - (testing "can handle multiple name conflicts" (load! (assoc snippet :entity_id id2)) (is (= (str unique-name " (copy) (copy)") (t2/select-one-fn :name :model/NativeQuerySnippet :entity_id id2)))) - (testing "will still update original one" (load! (assoc snippet :content "11 = 11")) (is (=? {:name unique-name @@ -1257,7 +1177,6 @@ (deftest snippet-template-tags-import-test (testing "Template tags import preserves nil, empty, and populated values" - (testing "Missing template_tags field -> {} when selected" (mt/with-empty-h2-app-db! (let [snippet-data {:serdes/meta [{:model "NativeQuerySnippet" @@ -1275,7 +1194,6 @@ :display-name "ID" :name "id"}} template-tags)))))) - (testing "Empty map template_tags -> preserved as empty map" (mt/with-empty-h2-app-db! (let [snippet-data {:serdes/meta [{:model "NativeQuerySnippet" @@ -1290,7 +1208,6 @@ (serdes.load/load-metabase! ingestion) (let [template-tags (t2/select-one-fn :template_tags :model/NativeQuerySnippet :entity_id "test-entity-2")] (is (= {} template-tags)))))) - (testing "Snippet template tags get preserved rather than recalculated" (mt/with-empty-h2-app-db! (let [snippet-data {:serdes/meta [{:model "NativeQuerySnippet" @@ -1364,23 +1281,18 @@ (reset! dash1s (ts/create! :model/Dashboard :name "My Dashboard")) (reset! tab1s (ts/create! :model/DashboardTab :name "Tab 1" :dashboard_id (:id @dash1s))) (reset! dashcard1s (ts/create! :model/DashboardCard :dashboard_id (:id @dash1s) :dashboard_tab_id (:id tab1s))) - (reset! serialized (into [] (serdes.extract/extract {:no-settings true}))))) - (testing "New dashcard will be removed on load" (ts/with-db dest-db (reset! dash1d (ts/create! :model/Dashboard :name "Weird Name" :entity_id (:entity_id @dash1s))) ;; A dashcard to be removed since it does not exist in serialized data (reset! dashcard2d (ts/create! :model/DashboardCard :dashboard_id (:id @dash1d))) (reset! tab2d (ts/create! :model/DashboardTab :name "Tab 2" :dashboard_id (:id @dash1d))) - ;; Load the serialized content. (serdes.load/load-metabase! (ingestion-in-memory @serialized)) - (reset! dash1d (-> (t2/select-one :model/Dashboard :name "My Dashboard") (t2/hydrate :dashcards) (t2/hydrate :tabs))) - (testing "Dashboard has correct number of dashcards" (is (= 1 (count (:dashcards @dash1d)))) @@ -1388,7 +1300,6 @@ (get-in @dash1d [:dashcards 0 :entity_id]))) (is (not= (:entity_id @dashcard1s) (:entity_id @dashcard2d)))) - (testing "Dashboard has correct number of tabs" (is (= 1 (count (:tabs @dash1d)))) @@ -1513,7 +1424,6 @@ (is (serdes.load/load-metabase! (ingestion-in-memory @serialized))) (is (= (:name card) (t2/select-one-fn :name :model/Card :id (:id card))))) - (testing "Partial load commits successful entities; failed entity does not persist" (t2/update! :model/Collection {:id (:id coll)} {:name (str "qwe_" (:name coll))}) (t2/update! :model/Card {:id (:id card)} {:name (str "qwe_" (:name card))}) @@ -1566,7 +1476,6 @@ "Card should be updated to the serialized value after retry") (is (= 2 @call-count) "Card load-update! should have been called twice (first attempt deadlocked, second succeeded)")))) - (testing "Non-transient errors propagate immediately without retry" (let [call-count (atom 0) load-update! serdes/load-update!] @@ -1580,7 +1489,6 @@ "Non-transient error should propagate") (is (= 1 @call-count) "Should not retry non-transient errors")))) - (testing "Successful entities are committed even when a later entity fails" (t2/update! :model/Collection {:id (:id coll)} {:name "pre-import"}) (t2/update! :model/Card {:id (:id card)} {:name "pre-import"}) @@ -1740,7 +1648,6 @@ (serdes.load/load-metabase! (ingestion-in-memory extracted)) (is (= {:other "secret"} (t2/select-one-fn :details :model/Database))))))))) - (mt/with-temp [:model/Database _ {:name "My Database" :details {:some "secret"}}] (testing "with :include-database-secrets" @@ -1788,16 +1695,13 @@ (vec (for [[_name e] {:coll coll :dash dash :c1 c1 :dc1 dc1}] [(t2/model e) (:id (t2/select-one (t2/model e) :entity_id (:entity_id e)))])))))) - (testing "Convert everything to using identity hashes" (t2/update! :model/Collection :id (:id coll) {:entity_id (serdes/identity-hash coll)}) (t2/update! :model/Dashboard :id (:id dash) {:entity_id (serdes/identity-hash dash)}) (t2/update! :model/Card :id (:id c1) {:entity_id (serdes/identity-hash c1)}) (t2/update! :model/DashboardCard :id (:id dc1) {:entity_id (serdes/identity-hash dc1)})) - (is (= 8 (count (serdes/entity-id "Card" (t2/select-one [:model/Card :entity_id] :id (:id c1)))))) - (testing "Identity hashes end up in target db in place of entity ids" (let [ser2 (vec (serdes.extract/extract {:no-settings true :no-data-model true :no-transforms true}))] (testing "\nWe exported identity hashes" @@ -1824,14 +1728,12 @@ :description "desc") ser (into [] (serdes.extract/extract {}))] - (is (=? {:parent_id ["mydb" nil "table" "field"] :serdes/meta [{:model "Database" :id "mydb"} {:model "Table" :id "table"} {:model "Field" :id "field"} {:model "Field" :id "field"}]} (ts/extract-one "Field" (:id f2)))) - (is (=? {:parent_id ["mydb" nil "table" "field" "field"] :serdes/meta [{:model "Database" :id "mydb"} {:model "Table" :id "table"} @@ -1839,11 +1741,8 @@ {:model "Field" :id "field"} {:model "Field" :id "field"}]} (ts/extract-one "Field" (:id f3)))) - (t2/update! :model/Field (:id f3) {:description "some new one"}) - (is (serdes.load/load-metabase! (ingestion-in-memory ser))) - (is (= "desc" (t2/select-one-fn :description :model/Field (:id f3))))))) @@ -1909,11 +1808,9 @@ {:targets [["Collection" (:id coll)]] :no-data-model true :no-settings true})))))) - (testing "serialized data contains table but not database" (is (some #(= "Table" (-> % :serdes/meta last :model)) @serialized)) (is (not-any? #(= "Database" (-> % :serdes/meta last :model)) @serialized))) - ;; Import to destination (where database already exists) (testing "import succeeds when database exists on target" (ts/with-db dest-db @@ -1921,7 +1818,6 @@ (let [target-db (ts/create! :model/Database :name "shared-db")] ;; Load the serialized content (serdes.load/load-metabase! (ingestion-in-memory @serialized)) - ;; Verify the table was imported correctly (let [imported-table (t2/select-one :model/Table :name "published_table") imported-coll (t2/select-one :model/Collection :entity_id @coll-eid)] @@ -1952,7 +1848,6 @@ {:targets [["Collection" (:id coll)]] :no-data-model true :no-settings true})))))) - (testing "import fails when database doesn't exist" (ts/with-db dest-db ;; Don't create the database - import should fail @@ -1974,7 +1869,6 @@ :definition (mbql5-segment-definition (:id db) (:id table) (:id field)) :creator_id (:id user))] (reset! serialized (into [] (serdes.extract/extract {}))))) - (let [minimal (mapv (fn [entity] (if (= "Segment" (-> entity :serdes/meta last :model)) (select-keys entity [:serdes/meta :entity_id :name :definition :creator_id]) @@ -2001,7 +1895,6 @@ :definition (mbql5-measure-definition (:id db) (:id table) (:id field)) :creator_id (:id user))] (reset! serialized (into [] (serdes.extract/extract {}))))) - (let [minimal (mapv (fn [entity] (if (= "Measure" (-> entity :serdes/meta last :model)) (select-keys entity [:serdes/meta :entity_id :name :definition :creator_id]) @@ -2030,7 +1923,6 @@ :dataset_query (mbql5-query (:id db) (:id table)) :display :line)] (reset! serialized (into [] (serdes.extract/extract {}))))) - (let [minimal (mapv (fn [entity] (if (= "Card" (-> entity :serdes/meta last :model)) (select-keys entity [:serdes/meta :entity_id :name :display :dataset_query @@ -2070,7 +1962,6 @@ :schema "public" :name "target_table"})] (reset! serialized (into [] (serdes.extract/extract {}))))) - (let [minimal (mapv (fn [entity] (if (= "Transform" (-> entity :serdes/meta last :model)) (select-keys entity [:serdes/meta :entity_id :name @@ -2111,7 +2002,6 @@ :name "hello_transforms_world")] (t2/update! :model/Table table-id {:transform_id (:id transform), :active true}) (reset! serialized (into [] (serdes.extract/extract {}))))) - (ts/with-db dest-db (t2/delete! :model/TransformTag) (serdes.load/load-metabase! (ingestion-in-memory @serialized)) @@ -2128,7 +2018,6 @@ (ts/with-db source-db (let [_dash (ts/create! :model/Dashboard :name "Test Dashboard")] (reset! serialized (into [] (serdes.extract/extract {}))))) - (let [minimal (mapv (fn [entity] (if (= "Dashboard" (-> entity :serdes/meta last :model)) (select-keys entity [:serdes/meta :entity_id :name :creator_id]) @@ -2147,7 +2036,6 @@ (let [dash (ts/create! :model/Dashboard :name "Test Dashboard") _dc (ts/create! :model/DashboardCard :dashboard_id (:id dash))] (reset! serialized (into [] (serdes.extract/extract {}))))) - (let [minimal (mapv (fn [entity] (let [model (-> entity :serdes/meta last :model)] (case model @@ -2179,7 +2067,6 @@ _series (ts/create! :model/DashboardCardSeries :dashboardcard_id (:id dc) :card_id (:id series-card) :position 0)] (reset! serialized (into [] (serdes.extract/extract {}))))) - (let [minimal (mapv (fn [entity] (let [model (-> entity :serdes/meta last :model)] (case model @@ -2221,7 +2108,6 @@ (is (some (fn [{[{:keys [model id]}] :serdes/meta}] (and (= model "Channel") (= id "Test Email Channel"))) @serialized)))) - (testing "loading into an empty database succeeds" (ts/with-db dest-db (serdes.load/load-metabase! (ingestion-in-memory @serialized)) @@ -2230,7 +2116,6 @@ (is (= "Test Email Channel" (:name (first channels)))) (is (= :channel/email (:type (first channels)))) (is (= "A test email channel" (:description (first channels))))))) - (testing "loading again does not duplicate" (ts/with-db dest-db (serdes.load/load-metabase! (ingestion-in-memory @serialized)) @@ -2251,7 +2136,6 @@ (is (some (fn [{[{:keys [model id]}] :serdes/meta}] (and (= model "Metabot") (= id @metabot-eid))) @serialized)))) - (testing "loading into an empty database succeeds" (ts/with-db dest-db (serdes.load/load-metabase! (ingestion-in-memory @serialized)) @@ -2259,7 +2143,6 @@ (is (= 1 (count metabots))) (is (= "Test Bot" (:name (first metabots)))) (is (= "A test metabot" (:description (first metabots))))))) - (testing "loading again does not duplicate" (ts/with-db dest-db (serdes.load/load-metabase! (ingestion-in-memory @serialized)) @@ -2275,7 +2158,6 @@ :details {:host "smtp.example.com" :port 587} :description "Some description") (reset! serialized (into [] (serdes.extract/extract {})))) - (let [minimal (mapv (fn [entity] (if (= "Channel" (-> entity :serdes/meta last :model)) (select-keys entity [:serdes/meta :name :type :details]) @@ -2296,7 +2178,6 @@ :description "Some description" :use_verified_content false) (reset! serialized (into [] (serdes.extract/extract {:include-metabot true})))) - (let [minimal (mapv (fn [entity] (if (= "Metabot" (-> entity :serdes/meta last :model)) (select-keys entity [:serdes/meta :entity_id :name]) @@ -2337,7 +2218,6 @@ (is (not (contains? card-ser :table_id)) "table_id should be stripped from export") (is (not (contains? card-ser :database_id)) "database_id should be stripped from export") (is (not (contains? card-ser :query_type)) "query_type should be stripped from export")))) - (ts/with-db dest-db (ts/create! :model/User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on") (serdes.load/load-metabase! (ingestion-in-memory @serialized)) @@ -2365,7 +2245,6 @@ (reset! serialized (into [] (serdes.extract/extract {}))) (let [seg-ser (first (filter #(= "Segment" (-> % :serdes/meta last :model)) @serialized))] (is (not (contains? seg-ser :table_id)) "table_id should be stripped from export")))) - (ts/with-db dest-db (ts/create! :model/User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on") (serdes.load/load-metabase! (ingestion-in-memory @serialized)) @@ -2389,7 +2268,6 @@ (reset! serialized (into [] (serdes.extract/extract {}))) (let [msr-ser (first (filter #(= "Measure" (-> % :serdes/meta last :model)) @serialized))] (is (not (contains? msr-ser :table_id)) "table_id should be stripped from export")))) - (ts/with-db dest-db (ts/create! :model/User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on") (serdes.load/load-metabase! (ingestion-in-memory @serialized)) @@ -2422,7 +2300,6 @@ (reset! serialized (into [] (serdes.extract/extract {}))) (let [card-ser (first (filter #(= "Card" (-> % :serdes/meta last :model)) @serialized))] (is (contains? card-ser :database_id) "database_id exported — not derivable from empty query")))) - (ts/with-db dest-db (ts/create! :model/User :first_name "Tom" :last_name "Scholz" :email "tom@bost.on") (serdes.load/load-metabase! (ingestion-in-memory @serialized)) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/models_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/models_test.clj index 8e140af3b113..ef9c5fd21033 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/models_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/models_test.clj @@ -84,7 +84,6 @@ (:copy spec) (:skip spec) (:copy spec) (keys (:transform spec)) (:skip spec) (keys (:transform spec)))) - (testing "Every column should be declared in serialization spec" (let [specs (->> (keys spec') (map name) @@ -92,10 +91,8 @@ fields (->> (keys fields) (map u/lower-case-en) set)] - (is (set/subset? fields specs) (format "Missing specs: %s" (pr-str (set/difference fields specs)))))) - (testing "Foreign keys should be declared as such\n" (doseq [[fk _] (filter #(:fk (second %)) fields) :let [fk (u/lower-case-en fk) @@ -104,12 +101,10 @@ (testing (format "`%s.%s` is foreign key which is handled correctly" m fk) ;; uses `(serdes/fk ...)` function (is (::serdes/fk transform))))) - (testing "created_at should be one of known timestamp types so we can catch others" (when-let [field-def (or (get fields "created_at") (get fields "CREATED_AT"))] (is (contains? datetime? (:type field-def))))) - (testing "Datetime fields should be declared as such\n" (doseq [[dt _] (filter #(datetime? (:type (second %))) fields) :let [dt (u/lower-case-en dt) @@ -117,7 +112,6 @@ :when (not= transform :skip)] (testing (format "`%s.%s` is datetime field which is handled correctly" m dt) (is (= (serdes/date) transform))))) - (testing "Nested models should declare `parent-ref`\n" (doseq [[_nested transform] (filter #(::serdes/nested (second %)) spec') :let [{:keys [model backward-fk]} transform @@ -125,7 +119,6 @@ (testing (format "%s has %s declared as `parent-ref`" model backward-fk) (is (= (serdes/parent-ref) (get-in inner-spec [:transform backward-fk])))))) - (testing ":defaults match actual DB field defaults" (let [serialized-fields (set (concat (:copy spec) (keys (:transform spec)))) declared-defaults (or (:defaults spec) {})] diff --git a/enterprise/backend/test/metabase_enterprise/serialization/v2/storage_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/v2/storage_test.clj index da4ec5e96cac..85a73b8144d4 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/v2/storage_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/v2/storage_test.clj @@ -39,7 +39,6 @@ (is (contains? (file-set (io/file dump-dir)) ["settings.yaml"]) "A few top-level files are expected")) - (testing "the Collections properly exported" (let [yaml-parent (-> (yaml/from-file (io/file dump-dir "collections" "main" "some_collection.yaml")) @@ -55,7 +54,6 @@ (update :created_at t/offset-date-time) (select-keys (keys yaml-parent))) yaml-parent)) - (is (= (-> (into {} (t2/select-one :model/Collection :id (:id child))) (dissoc :id :location) (assoc :parent_id (:entity_id parent)) @@ -138,7 +136,6 @@ (is (contains? (file-set (io/file dump-dir "databases" "my_company_data" "tables")) ["orders__SLASH__invoices" "orders__SLASH__invoices.yaml"]) "Slashes in directory names get escaped")) - (testing "the Field was properly exported" (is (= (ts/extract-one "Field" (:id website)) (-> (yaml/from-file (io/file dump-dir @@ -300,7 +297,6 @@ (fn [transform opts batch] (update-vals (original-fn transform opts batch) reverse))] (clean-and-export!))] - (testing "Dashcard ordering should be stable regardless of DB return order" (is (= yaml-before yaml-reversed) "Dashboard YAML should be identical even when DB returns dashcards in different order"))))))) @@ -331,22 +327,18 @@ (is (= ["my_collection" "some_card"] (resolve-path fns [{:label "My Collection" :key "coll-1"} {:label "Some Card" :key "card-1"}]))))) - (testing "special characters are replaced with underscores" (let [fns (atom {})] (is (= ["hello_world_"] (resolve-path fns [{:label "Hello World!" :key "a"}]))))) - (testing "slashes are escaped" (let [fns (atom {})] (is (= ["orders__SLASH__invoices"] (resolve-path fns [{:label "Orders/Invoices" :key "a"}]))))) - (testing "backslashes are escaped" (let [fns (atom {})] (is (= ["c__BACKSLASH__d"] (resolve-path fns [{:label "C\\D" :key "a"}]))))) - (testing "deduplication within the same folder" (let [fns (atom {})] (is (= ["my_card"] @@ -354,7 +346,6 @@ (is (= ["my_card_2"] (resolve-path fns [{:label "My Card" :key "card-2"}])) "second entity with same name in same folder gets _2 suffix"))) - (testing "same name in different folders does not conflict" (let [fns (atom {})] (is (= ["folder_a" "readme"] @@ -364,7 +355,6 @@ (resolve-path fns [{:label "Folder B" :key "f-b"} {:label "README" :key "doc-2"}])) "same leaf name under different parents is fine"))) - (testing "same key with same slug is stable" (let [fns (atom {})] (is (= ["my_card"] @@ -372,17 +362,14 @@ (is (= ["my_card"] (resolve-path fns [{:label "My Card" :key "card-1"}])) "re-resolving the same key+label returns the same result"))) - (testing "unicode is preserved" (let [fns (atom {})] (is (= ["données"] (resolve-path fns [{:label "Données" :key "a"}]))))) - (testing "dots are preserved" (let [fns (atom {})] (is (= ["parent.child"] (resolve-path fns [{:label "parent.child" :key "a"}]))))) - (testing "duplicate parent folder names with different keys" (let [fns (atom {})] (is (= ["my_folder" "card_a"] @@ -396,11 +383,9 @@ (resolve-path fns [{:label "My Folder" :key "folder-3"} {:label "Card C" :key "card-c"}])) "third folder with same name gets _3 suffix"))) - (testing "empty path returns empty vector" (let [fns (atom {})] (is (= [] (resolve-path fns []))))) - (testing "same slug under different parent paths does not collide" (let [fns (atom {})] (is (= ["collections" "transforms" "my_transform"] diff --git a/enterprise/backend/test/metabase_enterprise/snippet_collections/models/native_query_snippet/permissions_test.clj b/enterprise/backend/test/metabase_enterprise/snippet_collections/models/native_query_snippet/permissions_test.clj index 37213ad5d536..fa802598ce61 100644 --- a/enterprise/backend/test/metabase_enterprise/snippet_collections/models/native_query_snippet/permissions_test.clj +++ b/enterprise/backend/test/metabase_enterprise/snippet_collections/models/native_query_snippet/permissions_test.clj @@ -33,7 +33,6 @@ (testing "should be allowed if you have native perms for at least one DB" (with-redefs [snippet.perms/has-any-native-permissions? (constantly true)] (test-perms* true))))) - (testing "if EE perms are enabled: " (mt/with-premium-features #{:snippet-collections} (with-redefs [snippet.perms/has-any-native-permissions? (constantly true)] diff --git a/enterprise/backend/test/metabase_enterprise/sso/api/oidc_test.clj b/enterprise/backend/test/metabase_enterprise/sso/api/oidc_test.clj index 3f3979127d82..b8ba9c0124b8 100644 --- a/enterprise/backend/test/metabase_enterprise/sso/api/oidc_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sso/api/oidc_test.clj @@ -53,11 +53,9 @@ (is (= "Test Okta" (:login-prompt result))) (is (= "**********et" (:client-secret result))) (is (= 1 (count (sso-settings/oidc-providers)))))) - (testing "rejects duplicate key" (is (= "An OIDC provider with key 'test-okta' already exists" (mt/user-http-request :crowberto :post 400 "ee/sso/oidc" test-provider)))) - (testing "rejects invalid key" (is (mt/user-http-request :crowberto :post 400 "ee/sso/oidc" (assoc test-provider :key "INVALID SLUG!"))))))))) @@ -70,12 +68,10 @@ (let [result (mt/user-http-request :crowberto :get 200 "ee/sso/oidc")] (is (= 1 (count result))) (is (= "**********et" (:client-secret (first result)))))) - (testing "gets single provider with masked secret" (let [result (mt/user-http-request :crowberto :get 200 "ee/sso/oidc/test-okta")] (is (= "test-okta" (:key result))) (is (= "**********et" (:client-secret result))))) - (testing "returns 404 for missing provider" (is (mt/user-http-request :crowberto :get 404 "ee/sso/oidc/nonexistent"))))))) @@ -88,14 +84,12 @@ (let [result (mt/user-http-request :crowberto :put 200 "ee/sso/oidc/test-okta" {:login-prompt "Updated Okta"})] (is (= "Updated Okta" (:login-prompt result))))) - (testing "preserves client secret when masked value is sent" (let [result (mt/user-http-request :crowberto :put 200 "ee/sso/oidc/test-okta" {:client-secret "**********et"}) stored (sso-settings/get-oidc-provider "test-okta")] (is (= "**********et" (:client-secret result))) (is (= "test-client-secret" (:client-secret stored))))) - (testing "returns 404 for missing provider" (is (mt/user-http-request :crowberto :put 404 "ee/sso/oidc/nonexistent" {:login-prompt "Updated"})))))))) @@ -114,11 +108,9 @@ (mt/with-temporary-setting-values [oidc-providers []] (testing "oidc-enabled is false with no providers" (is (false? (sso-settings/oidc-enabled?))))) - (mt/with-temporary-setting-values [oidc-providers [(assoc test-provider :enabled true)]] (testing "oidc-configured is true when provider has required fields" (is (true? (sso-settings/oidc-enabled?))))) - (mt/with-temporary-setting-values [oidc-providers [(assoc test-provider :enabled false)]] (testing "oidc-enabled is false when no provider is enabled" (is (false? (sso-settings/oidc-enabled?)))))))) diff --git a/enterprise/backend/test/metabase_enterprise/sso/api/saml_test.clj b/enterprise/backend/test/metabase_enterprise/sso/api/saml_test.clj index c6267c04172a..29b7c8f294ec 100644 --- a/enterprise/backend/test/metabase_enterprise/sso/api/saml_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sso/api/saml_test.clj @@ -28,7 +28,6 @@ (mt/user-http-request :crowberto :put 200 "saml/settings" {:saml-keystore-path nil :saml-keystore-password nil :saml-keystore-alias nil})) - (testing "Invalid SAML settings returns 400" (mt/user-http-request :crowberto :put 400 "saml/settings" {:saml-keystore-path "/path/to/keystore" :saml-keystore-password "password" diff --git a/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj b/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj index 8a8e502c126b..6016bbbb4807 100644 --- a/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj @@ -56,7 +56,6 @@ :message "SSO has not been enabled and/or configured", :status "error-sso-disabled"} (client/client :get 400 "/auth/sso"))) - (testing "SSO requests fail if they don't have a valid premium-features token" (sso.test-setup/call-with-default-jwt-config! (fn [] @@ -68,7 +67,6 @@ :message "SSO has not been enabled and/or configured", :status "error-sso-disabled"} (client/client :get 400 "/auth/sso"))))))))) - (testing "SSO requests fail if JWT is enabled but hasn't been configured" (mt/with-temporary-setting-values [jwt-enabled @@ -82,7 +80,6 @@ :message "SSO has not been enabled and/or configured", :status "error-sso-disabled"} (client/client :get 400 "/auth/sso"))))) - (testing "SSO requests fail if JWT is configured but hasn't been enabled" (mt/with-temporary-setting-values [jwt-enabled @@ -98,7 +95,6 @@ :message "SSO has not been enabled and/or configured", :status "error-sso-disabled"} (client/client :get 400 "/auth/sso"))))) - (testing "The JWT idp uri must also be included for SSO to be configured" (mt/with-temporary-setting-values [jwt-enabled true @@ -111,7 +107,6 @@ :message "SSO has not been enabled and/or configured", :status "error-sso-disabled"} (client/client :get 400 "/auth/sso"))))) - (testing "The JWT Shared Secret must also be included for SSO to be configured" (mt/with-temporary-setting-values [jwt-enabled true @@ -170,20 +165,17 @@ (is (= {"extra" "keypairs", "are" "also present"} (t2/select-one-fn :jwt_attributes :model/User :email "rasta@metabase.com")))))) - (testing "with SAML and JWT configured, a GET request without JWT params should redirect to SAML IdP" (let [response (client/client-full-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} :return_to default-redirect-uri)] (is (not (sso.test-setup/successful-login? response))))) - (testing "with SAML and JWT configured, a POST without jwt in JSON body dispatches to SAML (not JWT login)" (let [response (client/client-real-response :post 401 "/auth/sso" {:request-options {:redirect-strategy :none}} {} :return_to default-redirect-uri)] (is (not (sso.test-setup/successful-login? response))))) - (testing "with SAML and JWT configured, a GET request with preferred_method=jwt should sign in via JWT" (let [response (client/client-real-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} @@ -206,7 +198,6 @@ (is (= {"extra" "keypairs", "are" "also present"} (t2/select-one-fn :jwt_attributes :model/User :email "rasta@metabase.com")))))) - (testing "with SAML and JWT configured, a GET request with preferred_method=saml should redirect to SAML IdP" (let [response (client/client-full-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} @@ -497,7 +488,6 @@ :last_name "User"} default-jwt-secret))] (is (sso.test-setup/successful-login? response))) - ;; then log in again (let [response (client/client-real-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} @@ -509,7 +499,6 @@ :last_name "User"} default-jwt-secret))] (is (sso.test-setup/successful-login? response)))))) - (testing "Existing user login attributes are not changed on subsequent logins" (with-jwt-default-setup! (mt/with-model-cleanup [:model/User] @@ -529,7 +518,6 @@ (testing "initial login attributes are stored" (is (= nil (t2/select-one-fn :login_attributes :model/User :email "existinguser@metabase.com"))))) - ;; Log in again with different attributes (let [response (client/client-real-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} @@ -563,11 +551,9 @@ :last_name "User"} default-jwt-secret))] (is (sso.test-setup/successful-login? response))) - ;; deactivate the user (t2/update! :model/User :email "newuser@metabase.com" {:is_active false}) (is (not (t2/select-one-fn :is_active :model/User :email "newuser@metabase.com"))) - (let [response (client/client-real-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} :return_to default-redirect-uri @@ -579,7 +565,6 @@ default-jwt-secret))] (is (sso.test-setup/successful-login? response)) (is (t2/select-one-fn :is_active :model/User :email "newuser@metabase.com"))) - ;; deactivate the user again (t2/update! :model/User :email "newuser@metabase.com" {:is_active false}) (is (not (t2/select-one-fn :is_active :model/User :email "newuser@metabase.com"))) @@ -952,20 +937,17 @@ "@attribute" "foo"} default-jwt-secret))] (is (sso.test-setup/successful-login? response)) - (testing "scalar attributes are stringified, array attributes are joined with commas, unstringable values dropped" (is (= {"string_attr" "valid-string" "number_attr" "42" "boolean_attr" "false" "array_attr" "item1,item2"} (t2/select-one-fn :jwt_attributes :model/User :email "rasta@metabase.com")))) - (testing "warning messages are logged for non-stringable values" (is (some #(re-find #"Dropping attribute 'object_attr' with non-stringable value: \{:nested \"value\"\}" %) (map :message (jwt-log-messages)))) (is (some #(re-find #"Dropping attribute 'null_attr' with non-stringable value: null" %) (map :message (jwt-log-messages))))) (testing "warning messages are logged for `@`-prefixed keys" (is (some #(re-find #"Dropping attribute '@attribute', keys beginning with `@` are reserved" %) (map :message (jwt-log-messages))))) - (testing "no warning for valid string attribute" (is (not (some #(re-find #"string_attr" %) (map :message (jwt-log-messages))))))))))) @@ -1278,7 +1260,6 @@ (is (some? tenant)) (is (= {"plan" "enterprise" "region" "us-west"} (:attributes tenant))))))))))) - (testing "Existing tenant - new attributes added, existing preserved" (with-jwt-default-setup! (mt/with-additional-premium-features #{:tenants} @@ -1305,7 +1286,6 @@ (is (= "enterprise" (get (:attributes tenant) "plan")))) (testing "new 'region' attribute is added" (is (= "us-east" (get (:attributes tenant) "region")))))))))))) - (testing "Invalid @tenant.attributes is ignored" (mt/with-model-cleanup [:model/Tenant] (with-jwt-default-setup! diff --git a/enterprise/backend/test/metabase_enterprise/sso/integrations/ldap_test.clj b/enterprise/backend/test/metabase_enterprise/sso/integrations/ldap_test.clj index d215a3199f21..96226d8aaf41 100644 --- a/enterprise/backend/test/metabase_enterprise/sso/integrations/ldap_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sso/integrations/ldap_test.clj @@ -27,7 +27,6 @@ "cn" "John Smith"} :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" @@ -40,7 +39,6 @@ "cn" "John Smith"} :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" @@ -52,7 +50,6 @@ "sn" "Taylor"} :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 @@ -64,7 +61,6 @@ "sn" "Miller"} :groups []} (ldap/find-user "jane.miller@metabase.com")))) - (mt/with-temporary-setting-values [ldap-group-membership-filter "memberUid={uid}"] (testing "find by username with custom group membership filter" (is (= {:dn "cn=Sally Brown,ou=People,dc=metabase,dc=com" @@ -78,7 +74,6 @@ "cn" "Sally Brown"} :groups ["cn=Engineering,ou=Groups,dc=metabase,dc=com"]} (ldap/find-user "sbrown20")))) - (testing "find by email with custom group membership filter" (is (= {:dn "cn=Sally Brown,ou=People,dc=metabase,dc=com" :first-name "Sally" diff --git a/enterprise/backend/test/metabase_enterprise/sso/integrations/saml_test.clj b/enterprise/backend/test/metabase_enterprise/sso/integrations/saml_test.clj index 047f873e6d0e..4662ca76368e 100644 --- a/enterprise/backend/test/metabase_enterprise/sso/integrations/saml_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sso/integrations/saml_test.clj @@ -143,18 +143,15 @@ saml-identity-provider-uri nil saml-identity-provider-certificate nil] (is (some? (client/client :get 400 "/auth/sso"))))) - (testing "SSO requests fail if SAML has been configured but not enabled" (mt/with-temporary-setting-values [saml-enabled false saml-identity-provider-uri default-idp-uri saml-identity-provider-certificate default-idp-cert] (is (some? (client/client :get 400 "/auth/sso"))))) - (testing "SSO requests fail if SAML is enabled but hasn't been configured" (mt/with-temporary-setting-values [saml-enabled true saml-identity-provider-uri nil] (is (some? (client/client :get 400 "/auth/sso"))))) - (testing "The IDP provider certificate must also be included for SSO to be configured" (mt/with-temporary-setting-values [saml-enabled true saml-identity-provider-uri default-idp-uri @@ -910,7 +907,6 @@ default-redirect-uri) response (client/client-real-response :post 302 "/auth/sso" req-options)] (is (successful-login? response)) - ;; Doesn't test the warning message because there are issues setting up log capture with client-real-response ;; and client-full-response doesn't work with the saml lib diff --git a/enterprise/backend/test/metabase_enterprise/sso/integrations/sso_utils_test.clj b/enterprise/backend/test/metabase_enterprise/sso/integrations/sso_utils_test.clj index a4dccc629508..99ea4d5d7bb0 100644 --- a/enterprise/backend/test/metabase_enterprise/sso/integrations/sso_utils_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sso/integrations/sso_utils_test.clj @@ -13,7 +13,6 @@ "localhost" "http://localhost:3000" "http://localhost:3000/dashboard/1-test-dashboard?currency=British%20Pound")) - (testing "check-sso-redirect- throws an error for invalid redirect URIs" (are [uri] (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid redirect URL" (sso-utils/check-sso-redirect uri)) "http://example.com" diff --git a/enterprise/backend/test/metabase_enterprise/sso/integrations/token_utils_test.clj b/enterprise/backend/test/metabase_enterprise/sso/integrations/token_utils_test.clj index a33a6128acdf..3efff0d4798f 100644 --- a/enterprise/backend/test/metabase_enterprise/sso/integrations/token_utils_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sso/integrations/token_utils_test.clj @@ -20,7 +20,6 @@ (is (re-matches #"[A-Za-z0-9%._-]+" token)) ;; Should be decodable as a URL-encoded string (is (string? (URLDecoder/decode token "UTF-8"))))) - (testing "should contain valid timestamp, expiration, and nonce when decrypted" (mt/with-temporary-setting-values [sdk-encryption-validation-key "1FlZMdousOLX9d3SSL+KuWq2+l1gfKoFM7O4ZHqKjTgabo7QdqP8US2bNPN+PqisP1QOKvesxkxOigIrvvd5OQ=="] (let [token (token-utils/generate-token) @@ -36,7 +35,6 @@ (is (> (- expiration timestamp) 299)) ;; Nonce should be a valid UUID string (is (re-matches #"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" nonce))))) - (testing "should generate different tokens on each call" (let [token1 (token-utils/generate-token) token2 (token-utils/generate-token)] @@ -46,7 +44,6 @@ (testing "validate-token" (mt/with-temporary-setting-values [sdk-encryption-validation-key "1FlZMdousOLX9d3SSL+KuWq2+l1gfKoFM7O4ZHqKjTgabo7QdqP8US2bNPN+PqisP1QOKvesxkxOigIrvvd5OQ=="] (let [encryption-key (encryption/secret-key->hash "1FlZMdousOLX9d3SSL+KuWq2+l1gfKoFM7O4ZHqKjTgabo7QdqP8US2bNPN+PqisP1QOKvesxkxOigIrvvd5OQ==")] - (testing "returns true for valid non-expired token" (let [now (t/instant) expiration (t/instant (t/plus now (t/seconds 300))) @@ -55,7 +52,6 @@ encrypted (encryption/encrypt encryption-key payload) token (URLEncoder/encode encrypted "UTF-8")] (is (true? (token-utils/validate-token token))))) - (testing "returns false for expired token" (let [now (t/instant) expiration (t/instant (t/minus now (t/seconds 10))) ;; 10 seconds in the past @@ -64,16 +60,12 @@ encrypted (encryption/encrypt encryption-key payload) token (URLEncoder/encode encrypted "UTF-8")] (is (false? (token-utils/validate-token token))))) - (testing "returns false for nil token" (is (false? (token-utils/validate-token nil)))) - (testing "returns false for empty string token" (is (false? (token-utils/validate-token "")))) - (testing "returns false for invalid token format" (is (false? (token-utils/validate-token "not-a-valid-token")))) - (testing "returns false for tampered token" (let [now (t/instant) expiration (t/instant (t/plus now (t/seconds 300))) diff --git a/enterprise/backend/test/metabase_enterprise/stale/api_test.clj b/enterprise/backend/test/metabase_enterprise/stale/api_test.clj index 6a27a77d8136..9c70c9551ec8 100644 --- a/enterprise/backend/test/metabase_enterprise/stale/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/stale/api_test.clj @@ -160,12 +160,10 @@ {:location (collection/children-location top-coll)}] (stale.test/with-stale-items [:model/Card card-in-root {:name "A Card in root"} :model/Dashboard dashboard-in-root {:name "B Dashboard in root"} - :model/Card card-in-top-level-coll {:name "C Card in coll" :collection_id top-coll-id} :model/Dashboard dashboard-in-top-level-coll {:name "D Dashboard in coll" :collection_id top-coll-id} - :model/Card card-in-child-coll {:name "E Card in coll" :collection_id child-coll-id} :model/Dashboard dashboard-in-child-coll {:name "F Dashboard in coll" @@ -173,11 +171,9 @@ (is (= [;; the first two items are in the root collection {:id nil :name nil :type nil :authority_level nil :effective_ancestors []} {:id nil :name nil :type nil :authority_level nil :effective_ancestors []} - ;; next we have two items in our top-level collection {:id top-coll-id :name top-coll-name :type nil :authority_level nil :effective_ancestors []} {:id top-coll-id :name top-coll-name :type nil :authority_level nil :effective_ancestors []} - ;; finally we have 2 items in our child collection {:id child-coll-id :name child-coll-name @@ -189,7 +185,6 @@ :type nil :authority_level nil :effective_ancestors [{:id top-coll-id :name (:name top-coll) :type nil :authority_level nil}]}] - (->> (mt/user-http-request :crowberto :get 200 "ee/stale/root" :is_recursive true :sort_column "name") :data diff --git a/enterprise/backend/test/metabase_enterprise/support_access_grants/provider_test.clj b/enterprise/backend/test/metabase_enterprise/support_access_grants/provider_test.clj index 2bc3f0a9b771..e22e98ae6f0c 100644 --- a/enterprise/backend/test/metabase_enterprise/support_access_grants/provider_test.clj +++ b/enterprise/backend/test/metabase_enterprise/support_access_grants/provider_test.clj @@ -96,7 +96,6 @@ :grant_start_timestamp (t/offset-date-time) :grant_end_timestamp (t/plus (t/offset-date-time) (t/hours 1)) :revoked_at nil}] - (let [token (sag.provider/create-support-access-reset! user-id grant) auth-identity (t2/select-one :model/AuthIdentity :user_id user-id diff --git a/enterprise/backend/test/metabase_enterprise/tenants/api_test.clj b/enterprise/backend/test/metabase_enterprise/tenants/api_test.clj index b8d0f25b690a..ca84f3a43503 100644 --- a/enterprise/backend/test/metabase_enterprise/tenants/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/tenants/api_test.clj @@ -414,7 +414,6 @@ (testing "attempting to archive tenant root collection returns 400" (mt/user-http-request :crowberto :put 400 (str "collection/" tenant-collection-id) {:archived true})) - (testing "tenant root collection remains unarchived" (is (false? (t2/select-one-fn :archived :model/Collection :id tenant-collection-id))))))))) @@ -430,7 +429,6 @@ (testing "archive child collection returns 200" (mt/user-http-request :crowberto :put 200 (str "collection/" child-id) {:archived true})) - (testing "child collection is archived" (is (t2/select-one-fn :archived :model/Collection :id child-id)))))))))) @@ -441,7 +439,6 @@ (mt/with-temp [:model/Tenant {tenant-collection-id :tenant_collection_id} {:name "Tenant Test" :slug "test"}] (testing "attempting to delete tenant root collection returns 400" (mt/user-http-request :crowberto :delete 400 (str "collection/" tenant-collection-id))) - (testing "tenant root collection still exists" (is (t2/exists? :model/Collection :id tenant-collection-id)))))))) @@ -457,7 +454,6 @@ :archived true}] (testing "deleting the collection is allowed" (mt/user-http-request :crowberto :delete 200 (str "collection/" child-id))) - (testing "child collection still exists" (is (not (t2/exists? :model/Collection :id child-id))))))))))) @@ -472,7 +468,6 @@ (testing "attempting to move tenant root collection returns 400" (mt/user-http-request :crowberto :put 400 (str "collection/" tenant-collection-id) {:parent_id target-id})) - (testing "tenant root collection location remains at root" (is (= "/" (t2/select-one-fn :location :model/Collection :id tenant-collection-id))))))))) @@ -491,7 +486,6 @@ (testing "can move child B under child A" (mt/user-http-request :crowberto :put 200 (str "collection/" child-b-id) {:parent_id child-a-id}) - (is (= (str "/" tenant-collection-id "/" child-a-id "/") (t2/select-one-fn :location :model/Collection :id child-b-id))))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/tenants/models_test.clj b/enterprise/backend/test/metabase_enterprise/tenants/models_test.clj index 99d5a32abb53..08cda94c5cd6 100644 --- a/enterprise/backend/test/metabase_enterprise/tenants/models_test.clj +++ b/enterprise/backend/test/metabase_enterprise/tenants/models_test.clj @@ -188,7 +188,6 @@ (let [coll (t2/select-one :model/Collection :id tenant-collection-id) localized-coll (collection/maybe-localize-tenant-collection-name coll)] (is (= "Tenant collection: My Tenant" (:name localized-coll)))) - (mt/with-current-user tenant-user (let [coll (t2/select-one :model/Collection :id tenant-collection-id) localized-coll (collection/maybe-localize-tenant-collection-name coll)] diff --git a/enterprise/backend/test/metabase_enterprise/tenants/permissions_api_test.clj b/enterprise/backend/test/metabase_enterprise/tenants/permissions_api_test.clj index 3c7f42421357..583530782925 100644 --- a/enterprise/backend/test/metabase_enterprise/tenants/permissions_api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/tenants/permissions_api_test.clj @@ -17,7 +17,6 @@ (let [group (t2/select-one :model/PermissionsGroup :name "Tenants Group")] (is (some? group)) (is (true? (:is_tenant_group group))))))) - (testing "validates is_tenant_group parameter type" (testing "rejects invalid type" (is (= {:errors {:is_tenant_group "nullable boolean"} diff --git a/enterprise/backend/test/metabase_enterprise/tenants/user_api_test.clj b/enterprise/backend/test/metabase_enterprise/tenants/user_api_test.clj index a5010874f6b4..5132f71d4f33 100644 --- a/enterprise/backend/test/metabase_enterprise/tenants/user_api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/tenants/user_api_test.clj @@ -29,12 +29,10 @@ (testing "response includes user_group_memberships" (is (= #{{:id (:id (perms/all-external-users-group))}} (set (:user_group_memberships resp))))) - (testing "user is actually assigned to all expected groups in database" (let [created-user (t2/select-one :model/User :email email)] (is (= #{"All tenant users"} (user-test/user-group-names created-user))))) - (testing "tenant_id is set correctly" (let [created-user (t2/select-one :model/User :email email)] (is (= tenant-id (:tenant_id created-user))))))))))))))) @@ -79,7 +77,6 @@ :tenant_id tenant-id :user_group_memberships [{:id (u/the-id (perms/all-external-users-group))} {:id normal-group-id}]})))) - (testing "internal users cannot be added to tenant groups via POST" (is (=? {:message "Cannot add non-tenant user to tenant-group or vice versa"} (mt/user-http-request :crowberto :post 400 "user" @@ -98,14 +95,12 @@ :is_tenant_group true} :model/PermissionsGroup {normal-group-id :id} {:name "Normal Group" :is_tenant_group false}] - (testing "tenant users cannot be added to non-tenant groups via PUT" (mt/with-temp [:model/User {external-user-id :id} {:tenant_id tenant-id}] (is (=? {:message "Cannot add non-tenant user to tenant-group or vice versa"} (mt/user-http-request :crowberto :put 400 (str "user/" external-user-id) {:user_group_memberships [{:id (u/the-id (perms/all-external-users-group))} {:id normal-group-id}]}))))) - (testing "internal users cannot be added to tenant groups via PUT" (mt/with-temp [:model/User {internal-user-id :id} {}] (is (=? {:message "Cannot add non-tenant user to tenant-group or vice versa"} @@ -120,7 +115,6 @@ (mt/with-temp [:model/Tenant {tenant-id :id} {:name "Test Tenant" :slug "test"} :model/PermissionsGroup {tenant-group-id :id} {:name "Tenant Group" :is_tenant_group true}] - (testing "cannot create tenant user as group manager via POST" (mt/with-model-cleanup [:model/User] (is (=? {:message "Tenant users cannot be made group managers"} @@ -132,7 +126,6 @@ :user_group_memberships [{:id (u/the-id (perms/all-external-users-group))} {:id tenant-group-id :is_group_manager true}]}))))) - (testing "cannot make external user group manager via PUT" (mt/with-temp [:model/User {external-user-id :id} {:tenant_id tenant-id}] ;; This test is expected to fail until group manager restrictions are implemented diff --git a/enterprise/backend/test/metabase_enterprise/transforms/core_test.clj b/enterprise/backend/test/metabase_enterprise/transforms/core_test.clj index 86f5e0b0b3f7..13a059e68f41 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms/core_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms/core_test.clj @@ -19,21 +19,17 @@ (is (= "transform-advanced" (premium-features/transform-metered-as :native) (premium-features/transform-metered-as :mbql)))) - (mt/with-premium-features #{:hosting :transforms-basic} (is (= "transform-basic" (premium-features/transform-metered-as :native) (premium-features/transform-metered-as :mbql)))) - (mt/with-premium-features #{:transforms-python} (is (= "transform-advanced" (premium-features/transform-metered-as :python)))) - (mt/with-premium-features #{} (is (= nil (premium-features/transform-metered-as :native) (premium-features/transform-metered-as :mbql)))) - (mt/with-premium-features #{:hosting :transforms-basic :transforms-python :writable-connection} (is (= nil (premium-features/transform-metered-as nil) @@ -62,7 +58,6 @@ (let [{id :id} (transform-run/start-run! transform-id {:run_method "manual"})] (transform-run/succeed-started-run! id) (t2/update! :model/TransformRun :id id {:end_time end-time})))] - (testing "with no premium features, mbql runs are not metered" (mt/with-premium-features #{} (run! mbql-id frozen-yesterday) @@ -74,7 +69,6 @@ :transform-rolling-advanced-runs 0 :transform-rolling-usage-date "2037-06-15"} (premium-features/transform-stats)))) - (testing "with :hosting :transforms-basic, mbql runs meter as basic" (mt/with-premium-features #{:hosting :transforms-basic} (run! mbql-id frozen-yesterday) @@ -86,7 +80,6 @@ :transform-rolling-advanced-runs 0 :transform-rolling-usage-date "2037-06-15"} (premium-features/transform-stats)))) - (testing "with :hosting :transforms-basic :writable-connection :transforms-python, both mbql and python runs meter as advanced" (mt/with-premium-features #{:hosting :transforms-basic :writable-connection :transforms-python} diff --git a/enterprise/backend/test/metabase_enterprise/transforms/incremental_test.clj b/enterprise/backend/test/metabase_enterprise/transforms/incremental_test.clj index 180262dec433..fff375747f29 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms/incremental_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms/incremental_test.clj @@ -241,15 +241,12 @@ (is (= "table-incremental" (-> transform :target :type))) (is (= "checkpoint" (-> transform :source :source-incremental-strategy :type))) (is (= expected-field-id (-> transform :source :source-incremental-strategy :checkpoint-filter-field-id))) - (testing "No checkpoint exists initially" (is (nil? (get-checkpoint-value (:id transform))))) - (testing "Can retrieve transform via API" (let [retrieved (mt/user-http-request :crowberto :get 200 (format "transform/%d" (:id transform)))] (is (= (:id transform) (:id retrieved))) (is (= "Test Incremental Transform" (:name retrieved))))) - (testing "Transform appears in list endpoint" (let [transforms (mt/user-http-request :crowberto :get 200 "transform") our-transform (first (filter #(= (:id transform) (:id %)) transforms))] @@ -277,12 +274,10 @@ distinct-timestamps (get-distinct-timestamp-count target-table)] (is (= 16 row-count) "First run should process all 16 products") (is (= 1 distinct-timestamps) "All rows should have the same timestamp from first run") - (testing "Checkpoint is created after first run" (let [checkpoint (get-checkpoint-value (:id transform))] (is (compare-checkpoint-values checkpoint-type expected-initial-checkpoint checkpoint) (format "Checkpoint should be MAX(%s) from all 16 rows" (:field-name checkpoint-config))))))) - (testing "Second run with no new data adds nothing" (let [transform (t2/select-one :model/Transform (:id transform))] (execute-transform-with-ordering! transform transform-type (:field-name checkpoint-config) {:run-method :manual}) @@ -290,7 +285,6 @@ distinct-timestamps (get-distinct-timestamp-count target-table)] (is (= 16 row-count) "Should still have 16 rows, no new data") (is (= 1 distinct-timestamps) "Should still have 1 distinct timestamp")))) - (when (and (isa? driver/hierarchy driver/*driver* :sql-jdbc) (not= driver/*driver* :clickhouse) (not= driver/*driver* :snowflake)) @@ -338,14 +332,12 @@ (is (= 16 row-count) "Initial run should process all 16 products") (is (= 1 distinct-timestamps) "All rows should have the same timestamp from first run") (is (compare-checkpoint-values checkpoint-type expected-initial-checkpoint checkpoint) "Checkpoint should be created"))) - (testing "Second incremental run with no new data" (execute-transform-with-ordering! (get-transform) transform-type (:field-name checkpoint-config) {:run-method :manual}) (let [row-count (get-table-row-count target-table) distinct-timestamps (get-distinct-timestamp-count target-table)] (is (= 16 row-count) "Should still have 16 rows, no new data") (is (= 1 distinct-timestamps) "Should still have 1 distinct timestamp"))) - (testing "Switch to non-incremental via PUT API" (let [non-incremental-payload (-> initial-payload (update :source dissoc :source-incremental-strategy) @@ -355,7 +347,6 @@ non-incremental-payload)] (is (= "table" (-> updated :target :type))) (is (nil? (-> updated :source :source-incremental-strategy))))) - (testing "Non-incremental run overwrites all data" (let [transform (t2/select-one :model/Transform (:id transform))] (execute-transform-with-ordering! transform transform-type (:field-name checkpoint-config) {:run-method :manual}) @@ -363,7 +354,6 @@ distinct-timestamps (get-distinct-timestamp-count target-table)] (is (= 16 row-count) "Should overwrite to 16 rows") (is (= 1 distinct-timestamps) "All rows should have same timestamp after non-incremental overwrite")) - (testing "Running again still overwrites" (execute-transform-with-ordering! transform transform-type (:field-name checkpoint-config) {:run-method :manual}) (let [row-count (get-table-row-count target-table) @@ -399,13 +389,11 @@ (testing "No checkpoint exists" (let [checkpoint (get-checkpoint-value (:id transform))] (is (nil? checkpoint) "No checkpoint for non-incremental transform"))))) - (testing "Switch to incremental via PUT API" (let [updated (mt/user-http-request :crowberto :put 200 (format "transform/%d" (:id transform)) incremental-payload)] (is (= "table-incremental" (-> updated :target :type))) (is (= "checkpoint" (-> updated :source :source-incremental-strategy :type))))) - (testing "First incremental run after switch recreates table with checkpoint" (let [transform (t2/select-one :model/Transform (:id transform))] (execute-transform-with-ordering! transform transform-type (:field-name checkpoint-config) {:run-method :manual}) @@ -415,7 +403,6 @@ (is (= 16 row-count) "Should have all 16 rows") (is (= 1 distinct-timestamps) "Should have 1 distinct timestamp") (is (compare-checkpoint-values checkpoint-type expected-initial-checkpoint checkpoint) "Checkpoint should be computed from existing data")))) - (testing "Second incremental run with no new data" (let [transform (t2/select-one :model/Transform (:id transform))] (execute-transform-with-ordering! transform transform-type (:field-name checkpoint-config) {:run-method :manual}) @@ -423,7 +410,6 @@ distinct-timestamps (get-distinct-timestamp-count target-table)] (is (= 16 row-count) "Should still have 16 rows, no new data") (is (= 1 distinct-timestamps) "Should still have 1 distinct timestamp")))) - (when (and (isa? driver/hierarchy driver/*driver* :sql-jdbc) ; insert/delete test products only works for jdbc drivers at the moment (not= driver/*driver* :clickhouse) ;; this *should* work see #68965 for context, will plan follow-up task @@ -668,13 +654,11 @@ (is (= 1 distinct-timestamps) "All rows should have the same timestamp from first run") (is (compare-checkpoint-values checkpoint-type expected-initial-checkpoint checkpoint) (format "Checkpoint should be MAX(%s) from all 16 rows" field-name)))) - (testing "Second run without new data adds nothing" (let [transform (t2/select-one :model/Transform (:id transform))] (transforms.execute/execute! transform {:run-method :manual}) (let [row-count (get-table-row-count target-table)] (is (= 16 row-count) "Should still have 16 rows, no new data")))) - (when (and (isa? driver/hierarchy driver/*driver* :sql-jdbc) (not= driver/*driver* :clickhouse) (not= driver/*driver* :snowflake)) @@ -684,7 +668,6 @@ :category "Electronics" :price 299.99 :created-at "2024-01-21T10:00:00"}] - (let [transform (t2/select-one :model/Transform (:id transform))] (transforms.execute/execute! transform {:run-method :manual}) (let [row-count (get-table-row-count target-table) diff --git a/enterprise/backend/test/metabase_enterprise/transforms/ordering_test.clj b/enterprise/backend/test/metabase_enterprise/transforms/ordering_test.clj index e088c75cd396..48d8539f1241 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms/ordering_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms/ordering_test.clj @@ -40,7 +40,6 @@ (is (contains? deps {:table-ref {:database_id (mt/id) :schema "public" :table "intermediate_output"}})))) - (testing "transform-ordering correctly resolves the dependency" (is (= {t-a #{} t-b #{t-a}} @@ -64,7 +63,6 @@ (is (contains? deps {:table-ref {:database_id (mt/id) :schema "public" :table "output_a"}})))) - (testing "transform-ordering resolves both dependencies" (is (= {t-a #{} t-b #{t-a}} diff --git a/enterprise/backend/test/metabase_enterprise/transforms/util_test.clj b/enterprise/backend/test/metabase_enterprise/transforms/util_test.clj index 6392269b9880..e2741043d0f0 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms/util_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms/util_test.clj @@ -20,7 +20,6 @@ (mt/with-premium-features #{:hosting :transforms-basic} (is (transforms.u/is-temp-transform-table? table-without-schema)) (is (transforms.u/is-temp-transform-table? table-with-schema))))) - (testing "Ignores non-transform tables" (mt/with-premium-features #{:transforms-basic} (is (false? (transforms.u/is-temp-transform-table? {:name :orders}))) diff --git a/enterprise/backend/test/metabase_enterprise/transforms_inspector/e2e_test.clj b/enterprise/backend/test/metabase_enterprise/transforms_inspector/e2e_test.clj index 22762dff328f..0e1af68b47aa 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms_inspector/e2e_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms_inspector/e2e_test.clj @@ -175,7 +175,6 @@ (let [step-cards (filter #(re-matches #"join-step-\d+" (:id %)) (get-in ja [:lens :cards]))] (is (= 3 (count step-cards))))) - (testing "all join-step cards return output_count and matched_count" (doseq [step [1 2 3] :let [r (join-step-result ja step)]] @@ -185,13 +184,11 @@ (str "step " step " should have rows")) (is (number? (get r "matched_count")) (str "step " step " should have matched_count"))))) - (testing "INNER JOIN (step 1) has 0% null rate" (let [r (join-step-result ja 1)] (when r (is (= 0 (get r "null_count")) "INNER JOIN should have no unmatched rows")))) - (testing "LEFT JOIN reviews (step 3) has non-zero null rate" (let [r (join-step-result ja 3)] (when r @@ -199,7 +196,6 @@ "LEFT JOIN reviews should have unmatched rows") (is (pos? (get r "null_rate")) "null_rate should be positive")))) - (testing "triggers fire for LEFT JOINs with unmatched rows" (let [{:keys [triggers]} ja] (is (seq (:alerts triggers)) @@ -209,7 +205,6 @@ (when (= source-type :mbql) (is (seq (:drill_lenses triggers)) "should have at least one drill lens trigger")))) - (when (= source-type :mbql) (testing "unmatched-rows drill lens fires and its cards execute" (let [{:keys [drills]} ja] diff --git a/enterprise/backend/test/metabase_enterprise/transforms_inspector/lens/core_test.clj b/enterprise/backend/test/metabase_enterprise/transforms_inspector/lens/core_test.clj index 39346ff70487..1afe0f18d246 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms_inspector/lens/core_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms_inspector/lens/core_test.clj @@ -89,7 +89,6 @@ :from-table-id 10 :native-context {:join-structure [{:strategy :left-join :source-table nil}]}}))) - (is (not (lens.core/lens-applicable? :join-analysis {:has-joins? true :from-table-id 10 diff --git a/enterprise/backend/test/metabase_enterprise/transforms_python/drivers_test.clj b/enterprise/backend/test/metabase_enterprise/transforms_python/drivers_test.clj index daf4f3ff5f64..1e785d8d3057 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms_python/drivers_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms_python/drivers_test.clj @@ -122,7 +122,6 @@ :columns columns}] (mt/as-admin (transforms-base.u/create-table-from-schema! driver db-id table-schema)) - (when (seq data) (driver/insert-from-source! driver db-id table-schema {:type :rows @@ -143,9 +142,7 @@ :else v)) row)) data)})) - (sync/sync-database! (mt/db) {:scan :schema}) - (t2/select-one-pk :model/Table :name (name qualified-table-name) :db_id db-id))) (defn- cleanup-table! @@ -183,17 +180,13 @@ rows (map json/decode lines) metadata (:output-manifest result) headers (map :name (:fields metadata))] - (testing "Column headers are correct" (is (= (set expected-columns) (disj (set headers) "_id")))) - (testing "Row count is correct" (is (= expected-row-count (count rows)))) - (testing "Metadata contains all expected columns" (is (= (set expected-columns) (disj (set (map :name (:fields metadata))) "_id")))) - {:headers headers :rows rows :metadata metadata}))) @@ -239,15 +232,12 @@ validation (execute-and-validate-transform! transform-code table-name table-id expected-columns expected-row-count)] - (when validation (testing (str "Exotic types for " driver-key) (let [{:keys [metadata]} validation type-map (u/for-map [{:keys [name base_type]} (:fields metadata)] [name (python-runner/restricted-insert-type base_type)])] - (is (isa? :type/Integer (type-map "id"))) - (case driver-key :postgres (do (is (isa? (type-map "bigint") :type/BigInteger)) @@ -287,7 +277,6 @@ expected-columns ["id" "name" "price" "active" "created_date" "created_at"] validation (execute-and-validate-transform! transform-code table-name table-id expected-columns 3)] - (when validation (let [{:keys [metadata]} validation] (testing "Base type preservation" @@ -342,12 +331,10 @@ "text_length" "int_doubled" "float_squared" "bool_inverted"] validation (execute-and-validate-transform! transform-code table-name table-id expected-columns 3)] - (when validation (let [{:keys [rows metadata]} validation type-map (u/for-map [{:keys [name base_type]} (:fields metadata)] [name (python-runner/restricted-insert-type base_type)])] - (testing "Original columns preserved" (is (isa? (type-map "id") (if (= driver/*driver* :snowflake) :type/Number :type/Integer))) (is (isa? (type-map "text_field") :type/Text)) @@ -355,13 +342,11 @@ (is (isa? (type-map "float_field") :type/Float)) (is (isa? (type-map "bool_field") :type/Boolean)) (is (isa? (type-map "date_field") (if (= driver/*driver* :mongo) :type/Instant :type/Date)))) - (testing "Computed columns have correct types" (is (isa? (type-map "text_length") :type/Integer)) (is (isa? (type-map "int_doubled") :type/Integer)) (is (isa? (type-map "float_squared") :type/Float)) (is (isa? (type-map "bool_inverted") :type/Boolean))) - (testing "Edge case data handling" (let [[row1 row2 row3] (sort-by #(get % "id") rows)] (is (= 1 (get row1 "id"))) @@ -396,17 +381,14 @@ (testing "Both transforms succeeded" (is (some? result1)) (is (some? result2))) - (when (and result1 result2) (testing "Results are identical" (is (= (:output result1) (:output result2))) (is (= (count (:fields (:output-manifest result1))) (count (:fields (:output-manifest result2)))))) - (let [expected-columns ["id" "name" "price" "active" "created_date" "created_at" "computed_field" "name_upper"] validation (validate-transform-output result1 expected-columns 3)] - (when validation (testing "Computed columns are added correctly" (let [{:keys [metadata]} validation @@ -430,7 +412,6 @@ (str source-table-name "_exotic") exotic-config (:data exotic-config)))] - (try (let [transform-code (str "import pandas as pd\n" "\n" @@ -466,10 +447,8 @@ ;; deal with non-deterministic order (e.g. bigquery) id-idx (u/index-of #{"id"} columns) result-rows (sort-by #(nth % id-idx) result-rows)] - (testing "E2E transform execution succeeded" (is (seq result-rows) "Transform should produce results")) - (testing "Expected data transformations" (let [first-row (first result-rows) second-row (second result-rows) @@ -478,30 +457,24 @@ (testing "Computed columns are present and have reasonable values" (is (> (count columns) 7) "Should have additional computed columns") (is (= 3 (count result-rows)) "Should have 3 rows from source data")) - (testing "Computed column values are mathematically correct" (testing "Row 1 price_with_tax calculation" (let [price-with-tax (get-column first-row "price_with_tax")] (is (and (number? price-with-tax) (> price-with-tax 21.5) (< price-with-tax 21.7)) "First row price_with_tax should be ~21.59"))) - (testing "Row 2 price_with_tax calculation" (let [price-with-tax (get-column second-row "price_with_tax")] (is (and (number? price-with-tax) (> price-with-tax 16.7) (< price-with-tax 16.8)) "Second row price_with_tax should be ~16.74"))) - (testing "Name length calculations" (is (== 9 (get-column first-row "name_length")) "First row name_length should be 9") (is (== 9 (get-column second-row "name_length")) "Second row name_length should be 9")) - (testing "Boolean expense calculations" (is (true? (get-column first-row "is_expensive")) "First row is_expensive should be true") (is (false? (get-column second-row "is_expensive")) "Second row is_expensive should be false")) - (testing "Date year extraction" (is (== 2024 (get-column first-row "created_year")) "First row created_year should be 2024") (is (== 2024 (get-column second-row "created_year")) "Second row created_year should be 2024")) - (testing "Null value handling" (is (seq third-row) "Third row should contain values (nulls handled gracefully)")))))) (finally diff --git a/enterprise/backend/test/metabase_enterprise/transforms_python/e2e_test.clj b/enterprise/backend/test/metabase_enterprise/transforms_python/e2e_test.clj index 0bd879243945..c2eb82d1dfd8 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms_python/e2e_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms_python/e2e_test.clj @@ -52,7 +52,6 @@ (transforms.tu/test-run transform-id) (transforms.tu/wait-for-table table-name 5000) (is (true? (driver/table-exists? driver/*driver* (mt/db) target))) - (let [rows (transforms.tu/table-rows table-name)] (is (= 5 (count rows))) (is (every? #(= (* 2 (first %)) (second %)) rows)) diff --git a/enterprise/backend/test/metabase_enterprise/transforms_python/execute_test.clj b/enterprise/backend/test/metabase_enterprise/transforms_python/execute_test.clj index 219c88b40f05..859d12289978 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms_python/execute_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms_python/execute_test.clj @@ -35,10 +35,8 @@ (mt/with-temp [:model/Transform transform initial-transform] (transforms-python.execute/execute-python-transform! transform {:run-method :manual}) (transforms.tu/wait-for-table table-name 10000) - (let [initial-rows (transforms.tu/table-rows table-name)] (is (= [["Alice" 25] ["Bob" 30]] initial-rows) "Initial data should be Alice and Bob") - (t2/update! :model/Transform (:id transform) {:source {:type "python" :source-tables [] @@ -47,7 +45,6 @@ "\n" "def transform():\n" " return pd.DataFrame({'name': ['Charlie', 'Diana', 'Eve'], 'age': [35, 40, 45]})")}})) - (let [swap-latch (CountDownLatch. 1) original-rename-tables-atomic! transforms-base.u/rename-tables!] (mt/with-dynamic-fn-redefs [transforms-base.u/rename-tables! (fn [driver db-id rename-pairs] @@ -87,14 +84,11 @@ (mt/with-temp [:model/Transform transform transform-def] (transforms-python.execute/execute-python-transform! transform {:run-method :manual}) (transforms.tu/wait-for-table table-name 10000) - (transforms-python.execute/execute-python-transform! transform {:run-method :manual}) - (let [db-id (mt/id) tables (t2/select :model/Table :db_id db-id :active true)] (is (not-any? transforms.u/is-temp-transform-table? tables) "No temp tables should remain after successful Python transform") - (is (= [[1 "a"] [2 "b"] [3 "c"]] (transforms.tu/table-rows table-name)) "Table should contain the expected data after swap"))))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/transforms_python/models/python_library_test.clj b/enterprise/backend/test/metabase_enterprise/transforms_python/models/python_library_test.clj index 11a19f18ed41..c6e71242ce6d 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms_python/models/python_library_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms_python/models/python_library_test.clj @@ -19,7 +19,6 @@ (is (= 1 (t2/count :model/PythonLibrary))) (is (= "def new_func(): return 1" (t2/select-one-fn :source :model/PythonLibrary)))) - (testing "updates existing record" (is (= 1 (t2/count :model/PythonLibrary))) (is (=? {:source "def updated_func(): return 2" @@ -31,7 +30,6 @@ (is (= 1 (t2/count :model/PythonLibrary)) "Should not create duplicate") (is (= "def updated_func(): return 2" (t2/select-one-fn :source :model/PythonLibrary)))) - (testing "rejects invalid paths" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid library path" @@ -49,7 +47,6 @@ :created_at some? :updated_at some?} (python-library/get-python-library-by-path "common")))) - (testing "rejects invalid paths" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid library path" @@ -59,10 +56,8 @@ (testing "normalize-path function" (testing "adds .py extension when missing" (is (= "common.py" (#'python-library/normalize-path "common")))) - (testing "doesn't duplicate .py extension" (is (= "common.py" (#'python-library/normalize-path "common.py")))) - (testing "works with paths that already have .py extension when updating" (t2/delete! :model/PythonLibrary) (python-library/update-python-library-source! "common.py" "def test(): pass") diff --git a/enterprise/backend/test/metabase_enterprise/transforms_python/python_runner_test.clj b/enterprise/backend/test/metabase_enterprise/transforms_python/python_runner_test.clj index b08bb6d660bb..e8728a53448b 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms_python/python_runner_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms_python/python_runner_test.clj @@ -187,7 +187,6 @@ " return students") result (execute! {:code transform-code :tables {"students" (mt/id :students)}})] - (is (=? {:output (jsonl-output [{:id 1 :name "Alice" :score 85} {:id 2 :name "Bob" :score 92} {:id 3 :name "Charlie" :score 88} @@ -260,7 +259,6 @@ " return result") result (execute! {:code transform-code :tables {"students" (mt/id :students)}})] - (is (=? {:output "{\"student_count\":0,\"average_score\":null}\n" :output-manifest {:schema_version 1 :data_format "jsonl" @@ -293,7 +291,6 @@ " return result") result (execute! {:code transform-code :tables {"students" (mt/id :students)}})] - (is (=? {:output "{\"student_count\":4,\"average_score\":88.75}\n" :output-manifest {:schema_version 1 :data_format "jsonl" @@ -347,18 +344,14 @@ [row1 row2 row3] rows get-col (fn [row col-name] (get row (keyword col-name))) metadata (:output-manifest result)] - (is (= (set ["id" "name" "description" "count" "price" "is_active" "created_date" "updated_at" "scheduled_for"]) (set headers))) - (is (= 1 (get-col row1 "id"))) (is (= "Product A" (get-col row1 "name"))) (is (datetime-equal? "2024-01-16T14:00:00Z" (get-col row1 "scheduled_for"))) - (is (= 2 (get-col row2 "id"))) (is (= "Product B" (get-col row2 "name"))) (is (datetime-equal? "2024-02-02T21:30:00Z" (get-col row2 "scheduled_for"))) - (is (= 3 (get-col row3 "id"))) (is (= "Product C" (get-col row3 "name"))) (is (datetime-equal? "2024-03-11T06:00:00Z" (get-col row3 "scheduled_for"))) @@ -482,11 +475,9 @@ :tables {table-name (mt/id qualified-table-name)}}) metadata (:output-manifest result)] - (testing "All expected columns are present" (is (= #{"id" "price" "active" "created_tz" "created_at" "created_date" "description"} (set (map :name (:fields metadata)))))) - (testing "types are preserved correctly" (is (= {"id" (if (= :snowflake driver) :type/BigInteger :type/Integer) "price" :type/Float @@ -500,7 +491,6 @@ "description" :type/Text} (u/for-map [{:keys [name base_type]} (:fields metadata)] [name (python-runner/restricted-insert-type base_type)])))) - ;; cleanup (driver/drop-table! driver db-id qualified-table-name))))) diff --git a/enterprise/backend/test/metabase_enterprise/transforms_python/s3_test.clj b/enterprise/backend/test/metabase_enterprise/transforms_python/s3_test.clj index 4bf49a76597f..ff548fc8dd5d 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms_python/s3_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms_python/s3_test.clj @@ -41,24 +41,17 @@ (let [bucket (transforms-python.settings/python-storage-s-3-bucket) key (str "test-object-" (random-uuid) ".txt") body (str "Hello, S3! My secret is:" (random-uuid))] - (is (nil? (s3/read-to-string s3-client bucket key))) (is (= :default (s3/read-to-string s3-client bucket key :default))) - (let [tmp-file (Files/createTempFile "s3-test" ".txt" (into-array FileAttribute []))] (try (spit (.toFile tmp-file) body) - (is (s3/upload-file s3-client bucket key (.toFile tmp-file))) - (is (= body (s3/read-to-string s3-client bucket key))) (is (= body (s3/read-to-string s3-client bucket key :default))) - (s3/delete s3-client bucket key) - (is (nil? (s3/read-to-string s3-client bucket key))) (is (= :default (s3/read-to-string s3-client bucket key :default))) - (finally (Files/deleteIfExists tmp-file)))))))) @@ -88,7 +81,6 @@ :put (do (is (http/put url {:body content})) (is (= content (s3/read-to-string s3-client bucket-name path :not-created))))))))) - (testing "closing the ref deletes the files" (.close storage-ref) (doseq [[k {:keys [path]}] objects] diff --git a/enterprise/backend/test/metabase_enterprise/transforms_python/transforms_api_test.clj b/enterprise/backend/test/metabase_enterprise/transforms_python/transforms_api_test.clj index f71856723aa0..d40fde55e336 100644 --- a/enterprise/backend/test/metabase_enterprise/transforms_python/transforms_api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/transforms_python/transforms_api_test.clj @@ -40,7 +40,6 @@ :database (mt/id)}}] (mt/user-http-request :lucky :post "transform" transform-payload)))] - (testing "without any feature flags" (mt/with-premium-features #{} (testing "creating python transform without any features fails" @@ -55,7 +54,6 @@ :schema (get-test-schema) :name "gadget_products" :database (mt/id)}})))))) - (testing "with only transforms-basic feature flag (no transforms-python)" (mt/with-premium-features #{:transforms-basic} (testing "creating python transform without transforms-python feature fails" @@ -70,7 +68,6 @@ :schema (get-test-schema) :name "gadget_products" :database (mt/id)}})))))) - (testing "with transforms-python feature flag" (mt/with-premium-features #{:transforms-basic :transforms-python} (with-transform-cleanup! [table-name "gadget_products"] @@ -215,7 +212,6 @@ (for [s program] (str " " s)) [" return pd.DataFrame({'x': [42]})"]) (str/join "\n"))) - (create-transform [{:keys [program]} target] {:post [(integer? %)]} (:id (mt/user-http-request :crowberto :post 200 "transform" @@ -225,7 +221,6 @@ :source-database (mt/id) :source-tables [(transforms.tu/default-source-table-entry)]} :target (assoc target :database (mt/id))}))) - (block-on-run [{:keys [expect-status]} target transform-id] (try (transforms.tu/test-run transform-id) @@ -235,7 +230,6 @@ (throw e)))) (when (= :succeeded expect-status) (transforms.tu/wait-for-table (:name target) 5000))) - (run-scenario [scenario schema] (with-redefs [transforms-python.execute/python-message-loop-sleep-duration (Duration/ofMillis fast-log-polling-ms) transforms-python.base/transfer-file-to-db (if-some [e (:writeback-ex scenario)] @@ -355,7 +349,6 @@ :source-database (mt/id) :source-tables [(transforms.tu/source-table-entry "transforms_customers" (mt/id :transforms_customers))]} :target (assoc target :database (mt/id))})) - ;; using clojure-ey coordination with promises (I know j.u.c could be better here) ;; goal is blocking at the right point during the run so that we can test the cancellation behaviour ;; for a particular stage during the run or area of interest (e.g. during a table copy out to shared storage) @@ -363,7 +356,6 @@ (await-signal [wait-signal] (when-not (deref wait-signal 5000 nil) (throw (ex-info "Expected delivery of wait signal within a reasonable amount of time" {})))) - (rf-proxy [ready-signal wait-signal rf] @@ -374,7 +366,6 @@ (deliver ready-signal true) (await-signal wait-signal) (rf w e)))) - (blocking-redefs [{:keys [block]} ready-signal wait-signal] (case block :read @@ -392,7 +383,6 @@ (await-signal wait-signal) (apply f args))}) nil)) - (run-scenario [{:keys [expect-script] :as scenario} target] (let [ready-signal (promise) ; test blocks: until the run is ready to be cancelled wait-signal (promise) ; run blocks: until the test has cancelled @@ -427,7 +417,6 @@ last-run (get-last-run transform-id)] {:messages @message-observer :last-run last-run})))))))] - (let [blocking-script ["import time" "import pandas as pd" @@ -463,7 +452,6 @@ :expect-script true :expect-write true ; note: the cancellation signal is currently ignored during the write phase :expect-status :canceled}]] - (doseq [{:keys [desc expect-status expect-script expect-write] :as scenario} scenarios] (mt/test-drivers (-> (mt/normal-drivers-with-feature :transforms/python) ; these drivers cause timing issues, could be fixed if we change timeout / time variables in test @@ -480,7 +468,6 @@ (let [scenario-result (run-scenario scenario target) {:keys [messages last-run]} scenario-result] (is (= (name expect-status) (:status last-run))) - (when (some? expect-script) (if expect-script (testing "script should have started" @@ -488,11 +475,9 @@ (testing "script should not have started" (is (not-any? #(str/includes? % "script started") messages)) (is (not (str/includes? (str (:message last-run)) "script started")))))) - (when (some? expect-write) (testing "table existence" (is (= expect-write (driver/table-exists? driver/*driver* (mt/db) target))))))))) - ; todo We have not yet covered the case where we rerun the same transform while there might be some hangover. ; Cancellation addresses the transform and not the run, there is shared mutable state and races on it are possible (testing "the runner is not blocked for a new run" @@ -522,7 +507,6 @@ (with-transform-cleanup! [{table-name :name :as target} {:type "table" :schema schema :name "schema_change_test"}] - (let [initial-transform {:name "Schema Change Integration Test" :source {:type "python" :source-database (mt/id) @@ -534,13 +518,11 @@ :target (assoc target :database (mt/id))} ;; Create initial transform via API {transform-id :id} (mt/user-http-request :crowberto :post 200 "transform" initial-transform)] - ;; Run initial transform and validate (transforms.tu/test-run transform-id) (transforms.tu/wait-for-table table-name 10000) (let [initial-rows (transforms.tu/table-rows table-name)] (is (= [["Alice" 25] ["Bob" 30]] initial-rows) "Initial data should be Alice and Bob with ages")) - ;; Update transform with different schema via API endpoint (let [updated-transform (assoc initial-transform :source {:type "python" @@ -553,11 +535,9 @@ update-response (mt/user-http-request :crowberto :put 200 (format "transform/%d" transform-id) updated-transform)] (is (some? update-response) "Transform update should succeed")) - ;; Run updated transform and validate schema change (transforms.tu/test-run transform-id) (transforms.tu/wait-for-transform-completion transform-id 10000) - ;; Sync runs after succeed-started-run! and activates new fields ;; before retiring old ones (non-transactional). Waiting for "age" ;; to be deactivated guarantees "friend" is already active too. @@ -589,7 +569,6 @@ (testing "Transform is created successfully" (is (integer? (:id response))) (is (= "python" (:source_type response)))) - ;; currently not allowed and used on UI so we're still converting this back to integer #_(testing "Source tables are preserved in response" (is (map? (get-in response [:source :source-tables :input]))) diff --git a/enterprise/backend/test/metabase_enterprise/upload_management/api_test.clj b/enterprise/backend/test/metabase_enterprise/upload_management/api_test.clj index 9329618ee552..d7c95068ac4d 100644 --- a/enterprise/backend/test/metabase_enterprise/upload_management/api_test.clj +++ b/enterprise/backend/test/metabase_enterprise/upload_management/api_test.clj @@ -71,7 +71,6 @@ (testing "Behind a feature flag" (mt/with-premium-features #{} ;; not :upload-management (mt/assert-has-premium-feature-error "Upload Management" (mt/user-http-request :crowberto :delete 402 (delete-url 1))))) - (mt/with-premium-features #{:upload-management} (testing "Happy path\n" (let [table-id (:id (oss-test/create-csv!))] @@ -81,17 +80,14 @@ (is (true? (mt/user-http-request :crowberto :delete 200 (delete-url table-id))))) (testing "The table is gone from the list" (is (not (contains? (listed-table-ids) table-id)))))) - (testing "Uploads may be deleted even when *uploading* has been disabled" (upload-test/with-uploads-disabled! (let [table-id (:id (oss-test/create-csv!))] (is (true? (mt/user-http-request :crowberto :delete 200 (delete-url table-id))))))) - (testing "The table must be uploaded" (mt/with-temp [:model/Table {table-id :id}] (is (= {:message "The table must be an uploaded table."} (mt/user-http-request :rasta :delete 422 (delete-url table-id)))))) - (testing "Write permissions to the table are required to delete it\n" (let [table-id (:id (oss-test/create-csv!))] (testing "The delete request is rejected" @@ -99,7 +95,6 @@ (mt/user-http-request :rasta :delete 403 (delete-url table-id))))) (testing "The table remains in the list" (is (contains? (listed-table-ids) table-id))))) - (testing "The archive_cards argument is passed through" (let [passed-value (atom nil)] (mt/with-dynamic-fn-redefs [upload/delete-upload! (fn [_ & {:keys [archive-cards?]}] diff --git a/modules/drivers/athena/test/metabase/driver/athena/hive_parser_test.clj b/modules/drivers/athena/test/metabase/driver/athena/hive_parser_test.clj index 97e23f3f5b20..d035775a0a66 100644 --- a/modules/drivers/athena/test/metabase/driver/athena/hive_parser_test.clj +++ b/modules/drivers/athena/test/metabase/driver/athena/hive_parser_test.clj @@ -29,4 +29,3 @@ :value {:note {:title "string" :description "string" :values []} :channels {:terminal "string" :app "string" :web "string"}}}]} (hive-schema->map "struct>,channels:struct>>>"))))) - diff --git a/modules/drivers/bigquery-cloud-sdk/src/metabase/driver/bigquery_cloud_sdk.clj b/modules/drivers/bigquery-cloud-sdk/src/metabase/driver/bigquery_cloud_sdk.clj index 44dec30c27ab..f6d8cce23c21 100644 --- a/modules/drivers/bigquery-cloud-sdk/src/metabase/driver/bigquery_cloud_sdk.clj +++ b/modules/drivers/bigquery-cloud-sdk/src/metabase/driver/bigquery_cloud_sdk.clj @@ -216,7 +216,6 @@ (let [[sql & params] (sql.qp/format-honeysql driver honeysql-form)] - (*process-native* (fn [cols results] (let [col-names (map (comp keyword :name) (:cols cols))] @@ -482,7 +481,6 @@ (let [details (driver.conn/effective-details database) project-id (get-project-id details) dataset-ids (or schema-names (list-datasets details))] - ;; The contract of [[driver/describe-fields]] requires results ordered by: ;; `table-schema`, `table-name`, `database-position` ;; @@ -754,7 +752,6 @@ (throw (ex-info "Null response from query" {})))) (catch Throwable t (deliver result-promise [:error t]))))] - ;; This `go` is responsible for cancelling the *initial* job execution. ;; Future pages may still not be fetched and so the reducer needs to check `cancel-chan` as well. (when cancel-chan @@ -762,7 +759,6 @@ (when-let [cancelled (a/ query-future future-cancel)))) - ;; Now block the original thread on that promise. ;; It will receive either [:ready [& respond-args]], [:error Throwable], or [:cancel truthy]. (let [[status result] @result-promise] @@ -1386,26 +1382,20 @@ project-id (get-project-id details) main-sa-email (.getClientEmail (ws-service-account-credentials details)) dataset-name (driver.u/workspace-isolation-namespace-name workspace)] - (try ;; Create the workspace service account (or get existing) (let [ws-sa-email (ws-create-service-account! iam-client project-id workspace) dataset-id (DatasetId/of project-id dataset-name)] - (log/infof "Initializing BigQuery workspace isolation: dataset=%s, service-account=%s" dataset-name ws-sa-email) - ;; Grant main SA permission to impersonate workspace SA (ws-grant-impersonation-permission! iam-client project-id main-sa-email ws-sa-email) - ;; Grant the workspace SA permission to run BigQuery jobs (queries) at project level ;; Note: We intentionally do NOT grant project-level dataEditor as that would give ;; access to all datasets. The workspace SA only gets dataEditor on its isolated dataset. (ws-grant-project-role! details project-id ws-sa-email "roles/bigquery.jobUser") - ;; Wait for IAM permissions to propagate by polling until impersonation works (ws-wait-for-impersonation-ready! details ws-sa-email) - ;; Create the isolated dataset if it doesn't exist (using main SA credentials, not impersonated) (when-not (.getDataset client dataset-id ^"[Lcom.google.cloud.bigquery.BigQuery$DatasetOption;" @@ -1416,11 +1406,9 @@ (.create client dataset-info ^"[Lcom.google.cloud.bigquery.BigQuery$DatasetOption;" (into-array BigQuery$DatasetOption [])))) - ;; Grant the workspace service account dataEditor role on the isolated dataset ;; dataEditor allows: create/update/delete tables, insert/update/delete data (ws-grant-dataset-acl! client dataset-id ws-sa-email "roles/bigquery.dataEditor") - ;; Return workspace connection details for impersonation ;; :user is used by grant-read-access-to-tables! to know which SA to grant access to ;; :impersonate-service-account is used by the connection swap to use impersonated credentials @@ -1437,9 +1425,7 @@ details (driver.conn/effective-details database) client (ws-database-details->client details) project-id (get-project-id details)] - (log/debugf "Granting read access to %d tables for %s" (count tables) ws-sa-email) - ;; Grant dataViewer at table level for each table - proper isolation (doseq [{:keys [schema name]} tables] (let [table-id (TableId/of project-id schema name)] @@ -1490,7 +1476,6 @@ dataset-id (DatasetId/of project-id dataset-name)] (try (log/infof "Destroying BigQuery workspace isolation: dataset=%s" dataset-name) - ;; Delete the dataset if it exists (deleteContents=true removes all tables) (when (.getDataset client dataset-id ^"[Lcom.google.cloud.bigquery.BigQuery$DatasetOption;" @@ -1500,10 +1485,8 @@ ^"[Lcom.google.cloud.bigquery.BigQuery$DatasetDeleteOption;" (into-array BigQuery$DatasetDeleteOption [(BigQuery$DatasetDeleteOption/deleteContents)])) (log/infof "Deleted dataset %s" dataset-name)) - ;; Delete the service account (this also removes its IAM bindings) (ws-delete-service-account! iam-client project-id workspace) - {:success true} (finally (.close iam-client))))) diff --git a/modules/drivers/bigquery-cloud-sdk/test/metabase/driver/bigquery_cloud_sdk/query_processor_test.clj b/modules/drivers/bigquery-cloud-sdk/test/metabase/driver/bigquery_cloud_sdk/query_processor_test.clj index 39288af20947..8ed5e74336b5 100644 --- a/modules/drivers/bigquery-cloud-sdk/test/metabase/driver/bigquery_cloud_sdk/query_processor_test.clj +++ b/modules/drivers/bigquery-cloud-sdk/test/metabase/driver/bigquery_cloud_sdk/query_processor_test.clj @@ -157,7 +157,6 @@ :params nil :table-name "venues" :mbql? true}) - (-> (mt/mbql-query venues {:aggregation [[:avg $category_id]] :breakout [$price] @@ -212,7 +211,6 @@ (is (= "2018-08-31T00:00:00Z" (native-timestamp-query (mt/id) "2018-08-31 00:00:00" "UTC")) "A UTC date is returned, we should read/return it as UTC") - (test.tz/with-system-timezone-id! "America/Chicago" (mt/with-temp [:model/Database db {:engine :bigquery-cloud-sdk :details (assoc (:details (mt/db)) @@ -222,7 +220,6 @@ (str "This test includes a `use-jvm-timezone` flag of true that will assume that the date coming from BigQuery " "is already in the JVM's timezone. The test puts the JVM's timezone into America/Chicago an ensures that " "the correct date is compared")))) - (test.tz/with-system-timezone-id! "Asia/Jakarta" (mt/with-temp [:model/Database db {:engine :bigquery-cloud-sdk :details (assoc (:details (mt/db)) @@ -1120,7 +1117,6 @@ " SELECT" " DATE_TRUNC(" " `v4_test_data.checkins`.`date`," - " month" " ) AS `date`," " COUNT(*) AS `count`" diff --git a/modules/drivers/bigquery-cloud-sdk/test/metabase/driver/bigquery_cloud_sdk_test.clj b/modules/drivers/bigquery-cloud-sdk/test/metabase/driver/bigquery_cloud_sdk_test.clj index 560985ff706b..3ed7a7dd5b2c 100644 --- a/modules/drivers/bigquery-cloud-sdk/test/metabase/driver/bigquery_cloud_sdk_test.clj +++ b/modules/drivers/bigquery-cloud-sdk/test/metabase/driver/bigquery_cloud_sdk_test.clj @@ -397,7 +397,6 @@ (into #{} (filter (comp #{view-name} :name)) (:tables (driver/describe-database :bigquery-cloud-sdk (mt/db)))))))) - (testing "We should be able to run queries against the view (#3414)" (is (= [[1 "Red Medicine" "Asian"] [2 "Stout Burgers & Beers" "Burger"] @@ -421,7 +420,6 @@ (into #{} (filter (comp #{view-name} :name)) (:tables (driver/describe-database :bigquery-cloud-sdk (mt/db)))))))) - (testing "We should be able to run queries against the view (#3414)" (is (= [[42]] (mt/rows @@ -555,12 +553,10 @@ "partition_by_range_not_required" "partition_by_ingestion_time_not_required"} :name)) (:tables (driver/describe-database :bigquery-cloud-sdk (mt/db)))))))) - (testing "tables that require a filter are correctly identified" (is (= table-name->is-filter-required? (t2/select-fn->fn :name :database_require_filter :model/Table :name [:in (keys table-name->is-filter-required?)])))) - (testing "partitioned fields are correctly identified" (is (= {["not_partitioned" "transaction_id"] false ["partition_by_range_not_required" "customer_id"] true @@ -708,7 +704,6 @@ :parameter_mappings [{:parameter_id "_NAME_" :card_id (:id card-product) :target [:dimension (mt/$ids $cf_product.name)]}]}] - (testing "chained filter works" (is (= {:has_more_values false :values [["Americano"] ["Cold brew"]]} @@ -1258,7 +1253,6 @@ [[12345678901234567890.1234567890M] [22345678901234567890.1234567890M] [32345678901234567890.1234567890M]]]]) - ;; Must sync field values (sync/sync-database! (mt/db)) (is (= "BIGNUMERIC" @@ -1381,4 +1375,3 @@ (is (= ["INSERT INTO `PRODUCTS_COPY` SELECT * FROM products" nil] (driver/compile-insert :bigquery-cloud-sdk {:query {:query "SELECT * FROM products"} :output-table :PRODUCTS_COPY})))))) - diff --git a/modules/drivers/clickhouse/src/metabase/driver/clickhouse_qp.clj b/modules/drivers/clickhouse/src/metabase/driver/clickhouse_qp.clj index b096c6989778..f3ac82c05eee 100644 --- a/modules/drivers/clickhouse/src/metabase/driver/clickhouse_qp.clj +++ b/modules/drivers/clickhouse/src/metabase/driver/clickhouse_qp.clj @@ -300,7 +300,6 @@ [:case [:< position 1] "" - :else [:'arrayElement [:'splitByString (sql.qp/->honeysql driver divider) [:'assumeNotNull (sql.qp/->honeysql driver text)]] diff --git a/modules/drivers/clickhouse/test/metabase/driver/clickhouse_introspection_test.clj b/modules/drivers/clickhouse/test/metabase/driver/clickhouse_introspection_test.clj index b315702648f0..b828cfa7bca7 100644 --- a/modules/drivers/clickhouse/test/metabase/driver/clickhouse_introspection_test.clj +++ b/modules/drivers/clickhouse/test/metabase/driver/clickhouse_introspection_test.clj @@ -568,7 +568,6 @@ (catch Throwable _e ::thrown))) "Sync should not throw an exception when encountering a parameterized view") - ;; Verify that the table AFTER the problematic view was still synced (let [table-after (t2/select-one :model/Table :db_id (u/the-id db) :name "table_after_view")] (is (some? table-after) diff --git a/modules/drivers/clickhouse/test/metabase/driver/clickhouse_substitution_test.clj b/modules/drivers/clickhouse/test/metabase/driver/clickhouse_substitution_test.clj index b8c89af1e921..e520da21b3f7 100644 --- a/modules/drivers/clickhouse/test/metabase/driver/clickhouse_substitution_test.clj +++ b/modules/drivers/clickhouse/test/metabase/driver/clickhouse_substitution_test.clj @@ -407,4 +407,3 @@ (is (= (str "select sum(value) from `uuid_filter_db`.`uuid_filter_table` " (format "where `uuid_filter_db`.`uuid_filter_table`.`uuid` IN (CAST('%s' AS UUID))" uuid-2)) (:query (qp.compile/compile-with-inline-parameters query))))))))) - diff --git a/modules/drivers/druid-jdbc/test/metabase/driver/druid_jdbc_test.clj b/modules/drivers/druid-jdbc/test/metabase/driver/druid_jdbc_test.clj index e9265f869f6f..f946ab4fe54f 100644 --- a/modules/drivers/druid-jdbc/test/metabase/driver/druid_jdbc_test.clj +++ b/modules/drivers/druid-jdbc/test/metabase/driver/druid_jdbc_test.clj @@ -349,7 +349,6 @@ {:aggregation [[:+ [:count $id] [:sum $venue_price]]] :breakout [$venue_price]}) mt/rows)))) - (testing "post-aggregation math w/ 3 args: count + sum + count" (is (= [[1 663] [2 2460] @@ -363,7 +362,6 @@ [:count $venue_price]]] :breakout [$venue_price]}) mt/rows)))) - (testing "post-aggregation math w/ a constant: count * 10" (is (= [[1 2210] [2 6150] @@ -374,7 +372,6 @@ {:aggregation [[:* [:count $id] 10]] :breakout [$venue_price]}) mt/rows)))) - (testing "nested post-aggregation math: count + (count * sum)" (is (= [[1 49062] [2 757065] @@ -387,7 +384,6 @@ [:* [:count $id] [:sum $venue_price]]]] :breakout [$venue_price]}) mt/rows)))) - (testing "post-aggregation math w/ avg: count + avg" (is (= [[1 721.8506787330316] [2 1116.388617886179] @@ -398,7 +394,6 @@ {:aggregation [[:+ [:count $id] [:avg $id]]] :breakout [$venue_price]}) mt/rows)))) - (testing "aggregation with math inside the aggregation :scream_cat:" (is (= [[1 442] [2 1845] @@ -409,7 +404,6 @@ {:aggregation [[:sum [:+ $venue_price 1]]] :breakout [$venue_price]}) mt/rows)))) - (testing "post aggregation math + math inside aggregations: max(venue_price) + min(venue_price - id)" (is (= [[1 -998] [2 -995] diff --git a/modules/drivers/druid/src/metabase/driver/druid/client.clj b/modules/drivers/druid/src/metabase/driver/druid/client.clj index c2868b12e0b2..9886df99f38d 100644 --- a/modules/drivers/druid/src/metabase/driver/druid/client.clj +++ b/modules/drivers/druid/src/metabase/driver/druid/client.clj @@ -32,7 +32,6 @@ options (cond-> (merge {:content-type "application/json;charset=UTF-8"} options) (:body options) (update :body json/encode) auth-enabled (assoc :basic-auth (str auth-username ":" auth-token-value)))] - (try (let [{:keys [status body]} (request-fn url options)] (when (not= status 200) diff --git a/modules/drivers/druid/test/metabase/driver/druid/client_test.clj b/modules/drivers/druid/test/metabase/driver/druid/client_test.clj index 993ad8be378e..04096697b250 100644 --- a/modules/drivers/druid/test/metabase/driver/druid/client_test.clj +++ b/modules/drivers/druid/test/metabase/driver/druid/client_test.clj @@ -23,7 +23,6 @@ (a/>!! running-chan ::running) (Thread/sleep 5000) (throw (Exception. "Don't actually run!")))] - (let [futur (future (qp/process-query query))] ;; wait for query to start running, then kill the thread running the query (a/go @@ -95,7 +94,6 @@ (get-auth-header get-request) (get-auth-header post-request) (get-auth-header delete-request)) "basic auth header included with successfully")) - (let [no-auth-basic false get-request (test-request druid.client/GET no-auth-basic) post-request (test-request druid.client/POST no-auth-basic) diff --git a/modules/drivers/druid/test/metabase/driver/druid/query_processor_test.clj b/modules/drivers/druid/test/metabase/driver/druid/query_processor_test.clj index 3dbfeef0cc8c..8e0c9446d0b6 100644 --- a/modules/drivers/druid/test/metabase/driver/druid/query_processor_test.clj +++ b/modules/drivers/druid/test/metabase/driver/druid/query_processor_test.clj @@ -445,7 +445,6 @@ (druid-query-returning-rows {:aggregation [[:+ [:count $id] [:sum $venue_price]]] :breakout [$venue_price]})))) - (testing "post-aggregation math w/ 3 args: count + sum + count" (is (= [["1" 663.0] ["2" 2460.0] @@ -457,7 +456,6 @@ [:sum $venue_price] [:count $venue_price]]] :breakout [$venue_price]})))) - (testing "post-aggregation math w/ a constant: count * 10" (is (= [["1" 2210.0] ["2" 6150.0] @@ -466,7 +464,6 @@ (druid-query-returning-rows {:aggregation [[:* [:count $id] 10]] :breakout [$venue_price]})))) - (testing "nested post-aggregation math: count + (count * sum)" (is (= [["1" 49062.0] ["2" 757065.0] @@ -477,7 +474,6 @@ [:count $id] [:* [:count $id] [:sum $venue_price]]]] :breakout [$venue_price]})))) - (testing "post-aggregation math w/ avg: count + avg" (is (= [["1" 721.8506787330316] ["2" 1116.388617886179] @@ -486,7 +482,6 @@ (druid-query-returning-rows {:aggregation [[:+ [:count $id] [:avg $id]]] :breakout [$venue_price]})))) - (testing "aggregation with math inside the aggregation :scream_cat:" (is (= [["1" 442.0] ["2" 1845.0] @@ -495,7 +490,6 @@ (druid-query-returning-rows {:aggregation [[:sum [:+ $venue_price 1]]] :breakout [$venue_price]})))) - (testing "post aggregation math + math inside aggregations: max(venue_price) + min(venue_price - id)" (is (= [["1" -998.0] ["2" -995.0] @@ -595,7 +589,6 @@ (compiled query))) (is (= [931 1 "Kinaree Thai Bistro"] (mt/first-row (qp/process-query query)))))) - (testing "topN query" (let [query (mt/mbql-query checkins {:aggregation [[:count]] @@ -606,7 +599,6 @@ (compiled query))) (is (= ["1" 221] (mt/first-row (qp/process-query query)))))) - (testing "groupBy query" (let [query (mt/mbql-query checkins {:aggregation [[:aggregation-options [:distinct $checkins.venue_name] {:name "__count_0"}]] @@ -620,7 +612,6 @@ :count ["Bar" "Felipinho Asklepios" 8] :venue_price ["Mexican" "Conchúr Tihomir" 4]) (mt/first-row (qp/process-query query)))))) - (testing "timeseries query" (let [query (mt/mbql-query checkins {:aggregation [[:count]] @@ -650,7 +641,6 @@ (parse-filter [:and [:= $venue_category_name [:value "Mexican" {:base_type :type/Text}]] - [:< !default.timestamp [:absolute-datetime #t "2015-10-01T00:00Z[UTC]" :default]]])))))))))) (deftest ^:parallel multiple-filters-test diff --git a/modules/drivers/druid/test/metabase/driver/druid/sync_test.clj b/modules/drivers/druid/test/metabase/driver/druid/sync_test.clj index 35ae56449275..6dd16223db3b 100644 --- a/modules/drivers/druid/test/metabase/driver/druid/sync_test.clj +++ b/modules/drivers/druid/test/metabase/driver/druid/sync_test.clj @@ -19,7 +19,6 @@ (testing "describe-database" (is (= {:tables #{{:schema nil, :name "checkins"}}} (driver/describe-database :druid (mt/db))))) - (testing "describe-table" (is (= {:schema nil :name "checkins" diff --git a/modules/drivers/mongo/src/metabase/driver/mongo.clj b/modules/drivers/mongo/src/metabase/driver/mongo.clj index d7ec7d3243a5..ef7ec830f5f2 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo.clj @@ -292,7 +292,6 @@ (->> (get-in ftree* (conj path :children)) keys (sort-by (juxt #(get-in ftree* (conj path :children % :index)) identity)) (map (partial conj path :children)))) - (ftree-prewalk* [ftree* path] (reduce ftree-prewalk* diff --git a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj index 1e1e0eaaf375..8e56ba7a7d74 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj @@ -725,7 +725,6 @@ function(bin) { (with-rvalue-temporal-bucketing {"$cond" [{"$eq" [{"$type" rvalue} "string"]} {"$toDate" rvalue} - rvalue]} :day))) @@ -1405,14 +1404,12 @@ function(bin) { ;; if there is only one breakout, always use the user's sort order (when (= (count id) 1) (window-sort id user-sort)) - ;; if we don't have a temporal breakout, sort by the last breakout, but ;; use the user's sort direction if specified (when-not finest-temporal-index (->> user-sort (filter #(= sort-name (first %))) (window-sort id))) - default-sort) partition-expr (into {} diff --git a/modules/drivers/mongo/test/metabase/driver/mongo/query_processor_test.clj b/modules/drivers/mongo/test/metabase/driver/mongo/query_processor_test.clj index 71ce1ef2de51..48db1ce2709b 100644 --- a/modules/drivers/mongo/test/metabase/driver/mongo/query_processor_test.clj +++ b/modules/drivers/mongo/test/metabase/driver/mongo/query_processor_test.clj @@ -233,7 +233,6 @@ (mt/mbql-query tips {:aggregation [[:count]] :filter [:= $tips.source.username "tupac"]})))) - (is (= {:projections ["source.username" "count"] :query [{"$group" {"_id" {"source" {"username" "$source.username"}} "count" {"$sum" 1}}} diff --git a/modules/drivers/mongo/test/metabase/driver/mongo_test.clj b/modules/drivers/mongo/test/metabase/driver/mongo_test.clj index f6de65d88d15..6d17a21d3dd2 100644 --- a/modules/drivers/mongo/test/metabase/driver/mongo_test.clj +++ b/modules/drivers/mongo/test/metabase/driver/mongo_test.clj @@ -292,26 +292,22 @@ ["multi-key-index" [{:field-name "url" :indexed? false :base-type :type/Text}] [[{:small "http://example.com/small.jpg" :large "http://example.com/large.jpg"}]]]) - (try (testing "singly index" (is (true? (t2/select-one-fn :database_indexed :model/Field (mt/id :singly-index :indexed)))) (is (false? (t2/select-one-fn :database_indexed :model/Field (mt/id :singly-index :not-indexed))))) - (testing "compound index" (mongo.connection/with-mongo-database [db (mt/db)] (mongo.util/create-index (mongo.util/collection db "compound-index") (array-map "first" 1 "second" 1))) (sync/sync-database! (mt/db)) (is (true? (t2/select-one-fn :database_indexed :model/Field (mt/id :compound-index :first)))) (is (false? (t2/select-one-fn :database_indexed :model/Field (mt/id :compound-index :second))))) - (testing "multi key index" (mongo.connection/with-mongo-database [db (mt/db)] (mongo.util/create-index (mongo.util/collection db "multi-key-index") (array-map "url.small" 1))) (sync/sync-database! (mt/db)) (is (false? (t2/select-one-fn :database_indexed :model/Field :name "url"))) (is (true? (t2/select-one-fn :database_indexed :model/Field :name "small")))) - (finally (t2/delete! :model/Database (mt/id))))))) @@ -380,7 +376,6 @@ {:field-name "text-field" :indexed? false :base-type :type/Text} {:field-name "geospatial-field" :indexed? false :base-type :type/Text}] [["Ngoc" "Khuat" [10 20]]]]) - (sync/sync-database! (mt/db)) (try (let [describe-indexes (fn [table-name] @@ -391,7 +386,6 @@ (is (= #{{:type :normal-column-index :value "_id"} {:type :normal-column-index :value "a"}} (describe-indexes :singly-index)))) - (testing "compound index column index" ;; first index column is :a (mongo.util/create-index (mongo.util/collection db "compound-index") (array-map :a 1 :b 1 :c 1)) @@ -401,7 +395,6 @@ {:type :normal-column-index :value "a"} {:type :normal-column-index :value "e"}} (describe-indexes :compound-index)))) - (testing "compound index that has many keys can still determine the first key" ;; first index column is :j (mongo.util/create-index (mongo.util/collection db "compound-index-big") @@ -409,13 +402,11 @@ (is (= #{{:type :normal-column-index :value "_id"} {:type :normal-column-index :value "j"}} (describe-indexes :compound-index-big)))) - (testing "multi key indexes" (mongo.util/create-index (mongo.util/collection db "multi-key-index") (array-map "a.b" 1)) (is (= #{{:type :nested-column-index :value ["a" "b"]} {:type :normal-column-index :value "_id"}} (describe-indexes :multi-key-index)))) - (testing "advanced-index: hashed index, text index, geospatial index" (mongo.util/create-index (mongo.util/collection db "advanced-index") (array-map "hashed-field" "hashed")) (mongo.util/create-index (mongo.util/collection db "advanced-index") (array-map "text-field" "text")) @@ -425,7 +416,6 @@ {:type :normal-column-index :value "_id"} {:type :normal-column-index :value "text-field"}} (describe-indexes :advanced-index)))))) - (finally (t2/delete! :model/Database (mt/id))))))) @@ -438,7 +428,6 @@ (mt/run-mbql-query tips {:aggregation [[:count]] :filter [:= $tips.source.username "tupac"]}))))) - (testing "Can we breakout against nested columns?" (is (= [[nil 297] ["amy" 20] @@ -577,19 +566,16 @@ (mt/rows (mt/run-mbql-query birds {:filter [:= $bird_id "abcdefabcdefabcdefabcdef"] :fields [$id $name $bird_id]}))))) - (testing "handle null ObjectId queries properly (#11134)" (is (= [[3 "Unlucky Raven" nil]] (mt/rows (mt/run-mbql-query birds {:filter [:is-null $bird_id] :fields [$id $name $bird_id]}))))) - (testing "treat null ObjectId as empty (#15801)" (is (= [[3 "Unlucky Raven" nil]] (mt/rows (mt/run-mbql-query birds {:filter [:is-empty $bird_id] :fields [$id $name $bird_id]})))))) - (testing "treat non-null ObjectId as not-empty (#15801)" (is (= [[1 "Rasta Toucan" (ObjectId. "012345678901234567890123")] [2 "Lucky Pigeon" (ObjectId. "abcdefabcdefabcdefabcdef")]] @@ -615,19 +601,16 @@ (mt/rows (mt/run-mbql-query birds {:filter [:= $bird_uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"] :fields [$id $name $bird_uuid]}))))) - (testing "handle null UUID queries properly" (is (= [[3 "Unlucky Raven" nil]] (mt/rows (mt/run-mbql-query birds {:filter [:is-null $bird_uuid] :fields [$id $name $bird_uuid]}))))) - (testing "treat null UUID as empty" (is (= [[3 "Unlucky Raven" nil]] (mt/rows (mt/run-mbql-query birds {:filter [:is-empty $bird_uuid] :fields [$id $name $bird_uuid]})))))) - (testing "treat non-null UUID as not-empty" (is (= [[1 "Rasta Toucan" #uuid "11111111-1111-1111-1111-111111111111"] [2 "Lucky Pigeon" #uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"]] @@ -830,7 +813,6 @@ (is (= {:version "4.0.28-23" :semantic-version [4 0 29]} (driver/dbms-version :mongo (mt/db)))))) - (testing "Any values after rubbish in versionArray are ignored" (with-redefs [mongo.util/run-command (constantly {"version" "4.0.28-23" "versionArray" [4 0 "NaN" 29]})] diff --git a/modules/drivers/oracle/test/metabase/driver/oracle_test.clj b/modules/drivers/oracle/test/metabase/driver/oracle_test.clj index b282c7c1debb..d81286856051 100644 --- a/modules/drivers/oracle/test/metabase/driver/oracle_test.clj +++ b/modules/drivers/oracle/test/metabase/driver/oracle_test.clj @@ -628,16 +628,12 @@ date-field (m/find-first (comp #{"Date"} :display-name) (lib/filterable-columns query))] (doseq [[x y] (partition-all 2 ["1970-01-01 00:00:00" "to_date('1970-01-01 00:00:00', 'YYYY-MM-DD HH24:MI:SS')" - "1970-01-01 10:09:08" "to_date('1970-01-01 10:09:08', 'YYYY-MM-DD HH24:MI:SS')" - "1970-01-01 10:09:08.000" "to_date('1970-01-01 10:09:08', 'YYYY-MM-DD HH24:MI:SS')" - "1970-01-01 10:09:08.001" "timestamp '1970-01-01 10:09:08.001'" - ;; Oracle can't resolve less than milliseconds, so cast to date since we don't lose anything "1970-01-01 10:09:08.0001" "to_date('1970-01-01 10:09:08', 'YYYY-MM-DD HH24:MI:SS')"])] diff --git a/modules/drivers/redshift/src/metabase/driver/redshift.clj b/modules/drivers/redshift/src/metabase/driver/redshift.clj index 9609b4993d59..5e6cac8ca570 100644 --- a/modules/drivers/redshift/src/metabase/driver/redshift.clj +++ b/modules/drivers/redshift/src/metabase/driver/redshift.clj @@ -467,13 +467,11 @@ [:< x y] [:> (extract :day x) (extract :day y)]] [:inline -1] - ;; if x>y but x x y] [:< (extract :day x) (extract :day y)]] [:inline 1] - :else [:inline 0]])) @@ -550,7 +548,6 @@ (keep (fn [param] (if (contains? param :name) [(:name param) (:value param)] - (when-let [field-id (driver-api/match-one param [:field (field-id :guard integer?) _] (when (perf/some #{:dimension} &parents) diff --git a/modules/drivers/redshift/test/metabase/driver/redshift_test.clj b/modules/drivers/redshift/test/metabase/driver/redshift_test.clj index cacf46ffc47a..b928292cf141 100644 --- a/modules/drivers/redshift/test/metabase/driver/redshift_test.clj +++ b/modules/drivers/redshift/test/metabase/driver/redshift_test.clj @@ -551,7 +551,6 @@ ["time" nil] ["timestamp" :type/DateTime] ["timestamptz" :type/DateTimeWithTZ] - ;; MySQL federated table enum types ["enum('A','B')" :type/Text] ["enum('open','closed')" :type/Text] @@ -652,7 +651,6 @@ (testing "returns one entry per table" (is (= 2 (count rows))) (is (= ["users" "organizations"] (mapv :table-name rows)))) - (testing "table rows have correct structure" (let [users-table (:table-row (first rows))] (is (= 123 (:db_id users-table))) @@ -661,7 +659,6 @@ (is (= "Users" (:display_name users-table))) (is (= "User accounts" (:description users-table))) (is (= "complete" (:initial_sync_status users-table))))) - (testing "auto PK field is injected at position 0" (let [users-fields (:field-rows (first rows)) pk-field (first users-fields)] @@ -669,7 +666,6 @@ (is (= "id" (:name pk-field))) (is (= :type/PK (:semantic_type pk-field))) (is (= 0 (:position pk-field))))) - (testing "user-defined fields have correct positions and types" (let [users-fields (:field-rows (first rows)) name-field (second users-fields) @@ -679,15 +675,12 @@ (is (= 1 (:position name-field))) (is (= :type/Text (:base_type name-field))) (is (= "User name" (:description name-field))) - (is (= "age" (:name age-field))) (is (= 2 (:position age-field))) (is (= :type/Integer (:base_type age-field))) - (is (= "org_id" (:name fk-field))) (is (= 3 (:position fk-field))) (is (= :type/FK (:semantic_type fk-field)) "FK fields get :type/FK semantic type")))))) - (testing "native type maps are handled correctly" (let [dbdef {:database-name "native-types" :table-definitions @@ -707,7 +700,6 @@ (is (= :type/Text (:effective_type raw-field))) ;; When effective-type is provided, base_type uses it; otherwise would be :type/* (is (= :type/Text (:base_type raw-field))))) - (testing "{:natives ...} form picks driver-specific type" (let [multi-field (nth fields 2)] (is (= "SUPER" (:database_type multi-field))) diff --git a/modules/drivers/snowflake/src/metabase/driver/snowflake.clj b/modules/drivers/snowflake/src/metabase/driver/snowflake.clj index ae9db9546c94..1654da0f7a31 100644 --- a/modules/drivers/snowflake/src/metabase/driver/snowflake.clj +++ b/modules/drivers/snowflake/src/metabase/driver/snowflake.clj @@ -521,12 +521,10 @@ [:< x y] [:> (time-zoned-extract :day x) (time-zoned-extract :day y)]] -1 - [:and [:> x y] [:< (time-zoned-extract :day x) (time-zoned-extract :day y)]] 1 - :else 0])) @@ -560,7 +558,6 @@ [:case [:< position 1] "" - :else [:split_part (sql.qp/->honeysql driver text) (sql.qp/->honeysql driver divider) position]])) diff --git a/modules/drivers/snowflake/test/metabase/driver/snowflake_test.clj b/modules/drivers/snowflake/test/metabase/driver/snowflake_test.clj index d7436bc9ffd2..5cb3fcef861b 100644 --- a/modules/drivers/snowflake/test/metabase/driver/snowflake_test.clj +++ b/modules/drivers/snowflake/test/metabase/driver/snowflake_test.clj @@ -429,20 +429,15 @@ (testing "dynamic-table?" (testing "returns true for dynamic table" (is (true? (#'driver.snowflake/dynamic-table? conn db-name (:schema dynamic-table) (:name dynamic-table))))) - (testing "returns false for normal table" (is (false? (#'driver.snowflake/dynamic-table? conn db-name (:schema normal-table) (:name normal-table))))) - (testing "returns false if db-name is invalid, make sure we don't throw an exception" (is (false? (#'driver.snowflake/dynamic-table? conn (mt/random-name) (:schema normal-table) (:name normal-table)))))) - (testing "sql-jdbc.describe-table/get-table-pks" (testing "returns empty array for dynamic table" (is (= [] (sql-jdbc.describe-table/get-table-pks :snowflake conn db-name dynamic-table)))) - (testing "also works if db-name is nil" (is (= [] (sql-jdbc.describe-table/get-table-pks :snowflake conn nil dynamic-table))))) - (testing "driver/describe-table-fks returns empty set for dynamic table" #_{:clj-kondo/ignore [:deprecated-var]} (is (= #{} (driver/describe-table-fks :snowflake (mt/db) dynamic-table))))))))))) @@ -670,7 +665,6 @@ expected-migrated (cond-> details-to-succeed uses-secret? (assoc :private-key-id secret-id) :always (dissoc :private-key-options :private-key-value :private-key-path))] - (testing "Migration persists as expected" (is (= expected-migrated migrated-details))) (testing "Migration results in unambiguous details" @@ -1222,7 +1216,6 @@ (testing "Last row has expected values" (is (= yesterday-last-str (ffirst (reverse rows))))))) - (testing "Rows should be properly allocated to days" (let [tested-day (assoc-in tested-field [2 :temporal-unit] :day) tested-minute (assoc-in tested-field [2 :temporal-unit] :minute)] @@ -1270,22 +1263,18 @@ (when (and password use-password) (is (= :password (first result)) (str [idxs result])))) - (testing "password comes last if use-password is false or nil" (when (and password (not use-password)) (is (= :password (last result)) (str [idxs result])))) - (testing "path is preferred if options is local" (when (and (= "local" options) private-key-value private-key-path) (is (= :private-key-path (m/find-first #{:private-key-path :private-key-value} result)) (str [idxs result])))) - (testing "value is preferred if options is nil or uploaded" (when (and (not= "local" options) private-key-value private-key-path) (is (= :private-key-value (m/find-first #{:private-key-path :private-key-value} result)) (str [idxs result])))) - (testing "ID is checked last if path or value exists" (when (or (and private-key-value private-key-id) (and private-key-path private-key-id)) @@ -1405,19 +1394,16 @@ [[5 "Enormous Marble Wallet" "Gadget"] [9 "Practical Bronze Computer" "Widget"] [11 "Ergonomic Silk Coat" "Gadget"]]] - ["case sensitive contains has rows" (lib/contains products-category "Gad") "CONTAINS(\"PUBLIC\".\"products\".\"category\", 'Gad')" [[5 "Enormous Marble Wallet" "Gadget"] [11 "Ergonomic Silk Coat" "Gadget"] [16 "Incredible Bronze Pants" "Gadget"]]] - ["case sensitive contains with no rows" (lib/contains products-category "gad") "CONTAINS(\"PUBLIC\".\"products\".\"category\", 'gad')" []] - ["case insensitive starts with has rows" (-> (lib/starts-with products-category "GAD") lib/ignore-case) @@ -1425,19 +1411,16 @@ [[5 "Enormous Marble Wallet" "Gadget"] [11 "Ergonomic Silk Coat" "Gadget"] [16 "Incredible Bronze Pants" "Gadget"]]] - ["case sensitive starts with has rows" (lib/starts-with products-category "Gad") "STARTSWITH(\"PUBLIC\".\"products\".\"category\", 'Gad')" [[5 "Enormous Marble Wallet" "Gadget"] [11 "Ergonomic Silk Coat" "Gadget"] [16 "Incredible Bronze Pants" "Gadget"]]] - ["case sensitive starts with has no rows" (lib/starts-with products-category "gad") "STARTSWITH(\"PUBLIC\".\"products\".\"category\", 'gad')" []] - ["case insensitive ends with has rows" (-> (lib/ends-with products-category "GET") lib/ignore-case) @@ -1445,14 +1428,12 @@ [[5 "Enormous Marble Wallet" "Gadget"] [9 "Practical Bronze Computer" "Widget"] [11 "Ergonomic Silk Coat" "Gadget"]]] - ["case sensitive ends with has rows" (lib/ends-with products-category "get") "ENDSWITH(\"PUBLIC\".\"products\".\"category\", 'get')" [[5 "Enormous Marble Wallet" "Gadget"] [9 "Practical Bronze Computer" "Widget"] [11 "Ergonomic Silk Coat" "Gadget"]]] - ["case sensitive ends with has no rows" (lib/ends-with products-category "GET") "ENDSWITH(\"PUBLIC\".\"products\".\"category\", 'GET')" diff --git a/modules/drivers/sqlite/test/metabase/driver/sqlite_test.clj b/modules/drivers/sqlite/test/metabase/driver/sqlite_test.clj index c07cd8bbe69d..2cece8d4e9dc 100644 --- a/modules/drivers/sqlite/test/metabase/driver/sqlite_test.clj +++ b/modules/drivers/sqlite/test/metabase/driver/sqlite_test.clj @@ -155,7 +155,6 @@ (testing "database should be synced" (is (= {:tables (set (map default-table-result ["timestamp_table"]))} (driver/describe-database driver db)))) - (testing "timestamp column should exist" (is (=? {:name "timestamp_table" :schema nil diff --git a/modules/drivers/sqlserver/test/metabase/driver/sqlserver_test.clj b/modules/drivers/sqlserver/test/metabase/driver/sqlserver_test.clj index 437ea7219c76..83df01875531 100644 --- a/modules/drivers/sqlserver/test/metabase/driver/sqlserver_test.clj +++ b/modules/drivers/sqlserver/test/metabase/driver/sqlserver_test.clj @@ -796,17 +796,14 @@ (let [database {:lib/type :metadata/database :details {:user "login_user" :role "db_user"}}] (is (= "db_user" (driver.sql/default-database-role :sqlserver database))))) - (testing "returns nil when no role is configured" (let [database {:lib/type :metadata/database :details {:user "login_user"}}] (is (nil? (driver.sql/default-database-role :sqlserver database))))) - (testing "returns nil even when user is 'sa'" (let [database {:lib/type :metadata/database :details {:user "sa"}}] (is (nil? (driver.sql/default-database-role :sqlserver database))))) - (testing "ignores user field and only uses role field" (let [database {:lib/type :metadata/database :details {:user "login_user" :role "impersonation_user"}}] @@ -830,7 +827,6 @@ {driver-api/qp.add.source-table (mt/id :venues) driver-api/qp.add.source-alias "name" driver-api/qp.add.desired-alias "name"}]]}}] - (is (= {:where [:= [::h2x/identifier :field ["JoinedCategories" "LiteralString"]] diff --git a/modules/drivers/starburst/src/metabase/driver/starburst.clj b/modules/drivers/starburst/src/metabase/driver/starburst.clj index f06179cebb64..b3488c74d541 100644 --- a/modules/drivers/starburst/src/metabase/driver/starburst.clj +++ b/modules/drivers/starburst/src/metabase/driver/starburst.clj @@ -947,7 +947,6 @@ (:tag driver-api/mb-version-info "") driver-api/local-process-uuid)) (cond-> (:prepared-optimized details-map) (assoc :explicitPrepare "false")) - ;; remove any Metabase specific properties that are not recognized by the starburst JDBC driver, which is ;; very picky about properties (throwing an error if any are unrecognized) ;; all valid properties can be found in the JDBC Driver source here: diff --git a/modules/drivers/starburst/test/metabase/driver/starburst_test.clj b/modules/drivers/starburst/test/metabase/driver/starburst_test.clj index 8c5848237f85..09b6d084cd40 100644 --- a/modules/drivers/starburst/test/metabase/driver/starburst_test.clj +++ b/modules/drivers/starburst/test/metabase/driver/starburst_test.clj @@ -48,7 +48,6 @@ (close [_] nil))))] (is (true? (sql-jdbc.sync.interface/have-select-privilege? :starburst mock-conn "sales_data" "hive_table"))))) - (testing "Returns false when DESCRIBE fails with UNSUPPORTED_TABLE_TYPE error (incompatible table type like Iceberg in Hive catalog)" (let [mock-conn (reify Connection (getCatalog [_] "hive") @@ -62,7 +61,6 @@ (close [_] nil))))] (is (false? (sql-jdbc.sync.interface/have-select-privilege? :starburst mock-conn "sales_data" "iceberg_table"))))) - (testing "Returns false for non-mixed-catalog errors" (let [mock-conn (reify Connection (getCatalog [_] "hive") diff --git a/src/metabase/activity_feed/models/recent_views.clj b/src/metabase/activity_feed/models/recent_views.clj index 5311cb052e27..fe90c4a7824c 100644 --- a/src/metabase/activity_feed/models/recent_views.clj +++ b/src/metabase/activity_feed/models/recent_views.clj @@ -283,7 +283,6 @@ [:and [:= :collection.id :card.collection_id] [:= :collection.archived false]] - [:report_dashboard :dashboard] [:= :dashboard.id :card.dashboard_id]]}))) @@ -417,7 +416,6 @@ [:in :id collection-ids] [:= :archived false]]})] (->> (t2/hydrate collections :effective_parent) - (map #(m/dissoc-in % [:effective_parent :type])))))) (defmethod fill-recent-view-info :collection [{:keys [_model model_id timestamp model_object]}] @@ -525,7 +523,6 @@ ;; only want to join on card_type if it's a card [:= :rv.model "card"] [:= :rc.id :rv.model_id]] - [:collection :coll] [:and [:= :rv.model "collection"] diff --git a/src/metabase/analytics/core.clj b/src/metabase/analytics/core.clj index 05f118644eb0..53e561706f25 100644 --- a/src/metabase/analytics/core.clj +++ b/src/metabase/analytics/core.clj @@ -22,31 +22,22 @@ (p/import-vars [metabase.analytics.llm-token-usage - track-snowplow! track-prometheus! track-token-usage!] - [metabase.analytics.util - hashed-metabase-token-or-uuid uuid->ai-service-hex-uuid] - [metabase.analytics.prometheus - known-labels initial-value connection-pool-info observe-initial-values setup! shutdown!] - [metabase.analytics.quartz - add-listeners-to-scheduler!] - [metabase.analytics.sdk - embedding-context? embedding-mw extract-hostname @@ -58,19 +49,13 @@ with-client! get-client get-route with-version! get-version] - [metabase.analytics.settings - anon-tracking-enabled anon-tracking-enabled! instance-creation] - [metabase.analytics.snowplow - track-event!] - [metabase.analytics.stats - environment-type legacy-anonymous-usage-stats phone-home-stats!]) diff --git a/src/metabase/analytics/prometheus.clj b/src/metabase/analytics/prometheus.clj index f37e75945849..bbf20b5b1fc8 100644 --- a/src/metabase/analytics/prometheus.clj +++ b/src/metabase/analytics/prometheus.clj @@ -269,7 +269,6 @@ (prometheus/counter :metabase-query-processor/query {:description "Did a query run by a specific driver succeed or fail" :labels [:driver :status]}) - (prometheus/histogram :metabase-remote-sync/export-duration-ms {:description "Duration in milliseconds that remote-sync exports took." ;; 1ms -> 10minutes @@ -292,7 +291,6 @@ (prometheus/counter :metabase-remote-sync/git-operations-failed {:description "Number of failed git operations" :labels [:operation :remote]}) - (prometheus/counter :metabase-search/index-reindexes {:description "Number of reindexed search entries" :labels [:model]}) @@ -405,7 +403,6 @@ {:description "Number of successful semantic search DLQ retries"}) (prometheus/counter :metabase-search/semantic-indexer-dlq-failures {:description "Number of failed semantic search DLQ retries"}) - ;; data-complexity-score timing ;; 1ms → 1min buckets; widen later if real-world runs push past a minute. (prometheus/histogram :metabase-data-complexity/scoring-duration-ms @@ -415,7 +412,6 @@ {:description "Duration (ms) of one stage (`enumerate` = DB fetch, `score` = in-memory scoring, `publish` = Snowplow emit) for one catalog within a Data Complexity Score run." :labels [:stage :catalog] :buckets [1 10 50 100 500 1000 5000 10000 30000 60000]}) - ;; notification metrics (prometheus/counter :metabase-notification/send-ok {:description "Number of successful notification sends." @@ -486,7 +482,6 @@ {:description "How many times the instance has deleted their Google Sheets connection."}) (prometheus/counter :metabase-gsheets/connection-manually-synced {:description "How many times the instance has manually sync'ed their Google Sheets connection."}) - ;; transform metrics (prometheus/counter :metabase-transforms/job-runs-total {:description "Total number of transform job runs started." @@ -647,7 +642,6 @@ (prometheus/counter :metabase-metabot/turn-started {:description "A metabot turn was started (user row + assistant placeholder inserted)" :labels [:profile-id]}) - ;; release dashboard metrics (prometheus/counter :metabase-sync/failures {:description "Number of sync operation failures." diff --git a/src/metabase/analyze/fingerprint/insights.clj b/src/metabase/analyze/fingerprint/insights.clj index c8b574782fc3..d2522ed51704 100644 --- a/src/metabase/analyze/fingerprint/insights.clj +++ b/src/metabase/analyze/fingerprint/insights.clj @@ -341,7 +341,6 @@ :numbers :else :others))) (resolve-agg-datetimes))] - (cond (timeseries? cols-by-type) (timeseries-insight cols-by-type) :else (fingerprinters/constant-fingerprinter nil)))) diff --git a/src/metabase/api/open_api.clj b/src/metabase/api/open_api.clj index 822e75d310c2..0cd43369215d 100644 --- a/src/metabase/api/open_api.clj +++ b/src/metabase/api/open_api.clj @@ -93,7 +93,6 @@ [:merge ::parameter.schema.common [:map - [:type ::parameter.type] ;; TODO -- I don't think `:null` can have `:enum` [:enum {:optional true} [:sequential :any]]]]) @@ -102,7 +101,6 @@ [:merge ::parameter.schema.typed.common [:map - [:type [:= :string]] [:format {:optional true} [:enum :binary "binary" :byte "byte" :uuid "uuid" :date-time "date-time"]] [:minLength {:optional true} integer?] @@ -113,7 +111,6 @@ [:merge ::parameter.schema.typed.common [:map - [:type [:= :number]] [:minimum {:optional true} number?] [:maximum {:optional true} number?]]]) @@ -122,7 +119,6 @@ [:merge ::parameter.schema.typed.common [:map - [:type [:= :integer]] [:minimum {:optional true} integer?] [:maximum {:optional true} integer?]]]) @@ -131,14 +127,12 @@ [:merge ::parameter.schema.typed.common [:map - [:type [:= :boolean]]]]) (mr/def ::parameter.schema.null [:merge ::parameter.schema.typed.common [:map - [:type [:= :null]]]]) (mr/def ::parameter.schema.object @@ -158,7 +152,6 @@ [:merge ::parameter.schema.typed.common [:map - [:type [:= :array]] [:items {:optional true} [:multi {:dispatch map?} @@ -189,7 +182,6 @@ [:merge ::parameter.schema.common [:map - [:$ref [:re {:description "string starting with '#/components/schemas/'"} #"^#/components/schemas/[^/]+$"]] @@ -206,34 +198,29 @@ [:merge ::parameter.schema.common [:map - [:anyOf [:sequential [:ref ::parameter.schema]]]]]] [:oneOf [:merge ::parameter.schema.common [:map - [:oneOf [:sequential [:ref ::parameter.schema]]]]]]]) (mr/def ::parameter.schema.and [:merge ::parameter.schema.common [:map - [:allOf [:sequential [:ref ::parameter.schema]]]]]) (mr/def ::parameter.schema.const [:merge ::parameter.schema.common [:map - [:const :any]]]) (mr/def ::parameter.schema.untyped-enum [:merge ::parameter.schema.common [:map - [:enum [:sequential :any]]]]) (mr/def ::parameter.schema.empty diff --git a/src/metabase/app_db/connection.clj b/src/metabase/app_db/connection.clj index 2c18214f25d3..8bd856c02fb5 100644 --- a/src/metabase/app_db/connection.clj +++ b/src/metabase/app_db/connection.clj @@ -156,7 +156,6 @@ (str "Error rolling back after previous error: " (ex-message txn-e)) {:rollback-error rollback-e} txn-e)))) - (throw txn-e)))))] ;; optimization: don't set and unset autocommit if it's already false (if (.getAutoCommit connection) diff --git a/src/metabase/app_db/core.clj b/src/metabase/app_db/core.clj index 736a19c15e68..f55ab7dca897 100644 --- a/src/metabase/app_db/core.clj +++ b/src/metabase/app_db/core.clj @@ -38,35 +38,26 @@ in-transaction? quoting-style unique-identifier] - [mdb.connection-pool-setup recent-activity?] - [mdb.data-source broken-out-details->DataSource] - [mdb.env db-file] - [mdb.jdbc-protocols clob->str] - [mdb.encryption decrypt-db encrypt-db] - [metabase.app-db.format format-sql] - [mdb.setup can-connect-to-data-source? migrate! quote-for-application-db] - [mdb.spec make-subname spec] - [metabase.app-db.query compile isa @@ -79,10 +70,8 @@ type-keyword->descendants update-or-insert! with-conflict-retry] - [metabase.app-db.query-cancelation query-canceled-exception?] - [liquibase changelog-by-id]) diff --git a/src/metabase/app_db/custom_migrations.clj b/src/metabase/app_db/custom_migrations.clj index c2439501e5c9..7028111cdffc 100644 --- a/src/metabase/app_db/custom_migrations.clj +++ b/src/metabase/app_db/custom_migrations.clj @@ -161,7 +161,6 @@ ;; For (almost) all v1 data paths, we simply extract the base path (e.g. "/db/1/schema/PUBLIC/table/1/") ;; and construct new v2 paths by adding prefixes to the base path. [(str "/data" base-path) (str "/query" base-path)] - ;; For the specific v1 path that grants full data access but no native query access, we add a ;; /schema/ suffix to the corresponding v2 query permission path. (when-let [db-id (second (re-find #"^/db/(\d+)/schema/$" v1-path))] @@ -1059,7 +1058,6 @@ (run! migrate! (t2/reducible-query {:select [:*] :from [:revision] :where [:= :model "Card"]})))) - (case (db-type*) :postgres (t2/query ["UPDATE revision diff --git a/src/metabase/app_db/custom_migrations/util.clj b/src/metabase/app_db/custom_migrations/util.clj index 392e8f86ebd5..c608ffbf24f6 100644 --- a/src/metabase/app_db/custom_migrations/util.clj +++ b/src/metabase/app_db/custom_migrations/util.clj @@ -44,7 +44,6 @@ (System/clearProperty "org.quartz.jobStore.acquireTriggersWithinLock") (when (qs/started? scheduler) (throw (ex-info "Scheduler is already started, cannot start temporary one" {}))) - (qs/start scheduler) (f scheduler) (qs/shutdown scheduler)))) diff --git a/src/metabase/app_db/data_source.clj b/src/metabase/app_db/data_source.clj index 5e1a276efccc..227577ed2b78 100644 --- a/src/metabase/app_db/data_source.clj +++ b/src/metabase/app_db/data_source.clj @@ -168,6 +168,5 @@ properties (some-> (not-empty (dissoc spec :classname :subprotocol :subname)) remove-shadowed-azure-managed-identity-client-id connection-pool/map->properties)] - (update-h2/update-if-needed! url) (->DataSource url properties))) diff --git a/src/metabase/app_db/liquibase.clj b/src/metabase/app_db/liquibase.clj index 57621cef837c..1c4846c692a8 100644 --- a/src/metabase/app_db/liquibase.clj +++ b/src/metabase/app_db/liquibase.clj @@ -579,7 +579,6 @@ latest-applied latest-available latest-applied) {:latest-available latest-available :latest-applied latest-applied}))) - (log/infof "Rolling back app database schema to version %d" target-version) (if (empty? ids-to-drop) (log/info "No changesets to roll back") diff --git a/src/metabase/app_db/setup.clj b/src/metabase/app_db/setup.clj index 78c638655b6b..3cfe612ab261 100644 --- a/src/metabase/app_db/setup.clj +++ b/src/metabase/app_db/setup.clj @@ -86,7 +86,6 @@ ;; Releasing the locks does not depend on the changesets, so we skip this step as it might require locking. (when-not (= :release-locks direction) (liquibase/consolidate-liquibase-changesets! conn liquibase)) - (log/info "Liquibase is ready.") (case direction :up (liquibase/migrate-up-if-needed! liquibase data-source) diff --git a/src/metabase/channel/api/slack.clj b/src/metabase/channel/api/slack.clj index 223fbc8916ee..09e99bb1e986 100644 --- a/src/metabase/channel/api/slack.clj +++ b/src/metabase/channel/api/slack.clj @@ -119,7 +119,6 @@ (setting/set-many! {:slack-app-token slack-app-token :slack-token-valid? true}) (slack/refresh-channels-and-usernames-when-needed!)))) - (when (contains? body :slack-bug-report-channel) (let [processed-bug-channel (channel.settings/process-files-channel-name slack-bug-report-channel)] (when (and processed-bug-channel @@ -127,7 +126,6 @@ (throw (ex-info (tru "Slack channel not found.") {:errors {:slack-bug-report-channel (tru "channel not found")}}))) (channel.settings/slack-bug-report-channel! processed-bug-channel))) - {:ok true} (catch clojure.lang.ExceptionInfo info {:status 400, :body (ex-data info)}))) diff --git a/src/metabase/channel/task/refresh_slack_channel_user_cache.clj b/src/metabase/channel/task/refresh_slack_channel_user_cache.clj index 22daa9b372a4..f2d38a3accfe 100644 --- a/src/metabase/channel/task/refresh_slack_channel_user_cache.clj +++ b/src/metabase/channel/task/refresh_slack_channel_user_cache.clj @@ -48,7 +48,6 @@ ;; run every 4 hours at a random minute: (format "0 %d 0/4 1/1 * ? *" (rand-int 60))) (cron/with-misfire-handling-instruction-do-nothing))) - (triggers/start-now)) startup-job (jobs/build (jobs/of-type RefreshCacheOnStartup) diff --git a/src/metabase/cloud_migration/models/cloud_migration.clj b/src/metabase/cloud_migration/models/cloud_migration.clj index 1325fba2c490..85bab77128c8 100644 --- a/src/metabase/cloud_migration/models/cloud_migration.clj +++ b/src/metabase/cloud_migration/models/cloud_migration.clj @@ -222,7 +222,6 @@ (try (when retry? (t2/update! :model/CloudMigration :id id {:state :init})) - (log/info "Setting read-only mode") (set-progress id :setup 1) (cloud-migration.settings/read-only-mode! true) @@ -231,7 +230,6 @@ (Thread/sleep (int (* 1.5 setting/cache-update-check-interval-ms)))) (log/info "Stopping scheduler") (task/stop-scheduler!) - (log/info "Dumping h2 backup to" (.getAbsolutePath dump-file)) (set-progress id :dump 20) (dump-to-h2/dump-to-h2! (.getAbsolutePath dump-file) {:dump-plaintext? true}) @@ -239,20 +237,16 @@ (throw (ex-info "Read-only mode disabled before h2 dump was completed, contents might not be self-consistent!" {:id id}))) (cloud-migration.settings/read-only-mode! false) - (log/info "Uploading dump to store") (set-progress id :upload 50) (upload migration dump-file) - (log/info "Notifying store that upload is done") (http/put (migration-url external_id "/uploaded")) - ;; Need to restore the previous scheduler configuration because the database quartz is pointing at has changed ;; after finishing the dump to h2 migration (task.bootstrap/set-jdbc-backend-properties! (mdb/db-type)) (log/info "Restarting scheduler") (task/start-scheduler!) - (log/info "Migration finished") (set-progress id :done 100) (catch Exception e diff --git a/src/metabase/collections/models/collection.clj b/src/metabase/collections/models/collection.clj index 8cd438cb7eec..5e4294f31d15 100644 --- a/src/metabase/collections/models/collection.clj +++ b/src/metabase/collections/models/collection.clj @@ -552,7 +552,6 @@ (or ;; If collection has an owner ID we're already done here, we know it's a Personal Collection (:personal_owner_id collection) - ;; Try to get the ID of its highest-level ancestor, e.g. if `location` is `/1/2/3/` we would get `1`. Then see if ;; the root-level ancestor is a Personal Collection (Personal Collections can only exist in the Root Collection.) (when-let [id (first (location-path->ids (:location collection)))] @@ -749,10 +748,8 @@ (and ;; we have permission for it. (can-access-root-collection? user-scope (:permission-level visibility-config)) - ;; we're not *only* looking for archived items (not= :only (:include-archived-items visibility-config)) - ;; we're not looking for a particular `archive_operation_id` (not (:archive-operation-id visibility-config))))) @@ -828,24 +825,20 @@ ;; hiding the trash collection when desired... (when-not (:include-trash-collection? visibility-config) [:not= [:inline (trash-collection-id)] :c.id]) - ;; hiding archived items when desired... (when (= :exclude (:include-archived-items visibility-config)) [:= :c.archived false]) - ;; (or showing them, if that's what you want) (when (= :only (:include-archived-items visibility-config)) [:or [:= :c.archived true] ;; the trash collection is included when viewing archived-only [:= :id [:inline (trash-collection-id)]]]) - (when-not (perms/use-tenants) [:not [:exists {:select [1] :from [[:collection :sub_c]] :where [:and [:= :c.id :sub_c.id] [:= :sub_c.namespace [:inline "shared-tenant-collection"]]]}]]) - ;; excluding things outside of the `archive_operation_id` you wanted... (when-let [op-id (:archive-operation-id visibility-config)] [:or @@ -894,7 +887,6 @@ [:and ;; an effective child is a descendant of the parent collection [:like (->col "location") (str (children-location parent-coll) "%")] - ;; but NOT a child of any OTHER visible collection. [:not [:exists {:select 1 :from [[:collection :c2]] @@ -1585,7 +1577,6 @@ :archived [:= true]))] (api/check-400 (and (some? new-parent) (not (:archived new-parent)))) - (if (contains? updates :parent_id) (api/check-403 (and (mi/can-write? new-parent) @@ -1595,7 +1586,6 @@ ;; Restoring to original location, use `can_restore` for a single source of truth (api/check-403 (:can_restore (t2/hydrate collection :can_restore)))) - (t2/with-transaction [_conn] (t2/update! :model/Collection (u/the-id collection) {:location new-location @@ -1888,7 +1878,6 @@ (let [msg (tru "You cannot move a Collection to a different namespace once it has been created.")] (throw (ex-info msg {:status-code 400, :errors {:namespace msg}}))))) (assert-valid-namespace (merge (select-keys collection-before-updates [:namespace]) collection-updates)) - ;; (3.6) Check that the parent collection allows this collection to be there (check-allowed-content (:type collection) (when-let [location (:location collection)] (location-path->parent-id location))) ;; (3.7) Check if it's a semantic-library collection that can't be updated @@ -1934,7 +1923,6 @@ :model/Pulse :model/Timeline]] (t2/delete! model :collection_id [:in affected-collection-ids]))) - ;; You can't delete a Personal Collection! Unless we enable it because we are simultaneously deleting the User (when-not *allow-deleting-personal-collections* (when (:personal_owner_id collection) @@ -2323,21 +2311,17 @@ (and ;; the item is archived (:archived item) - ;; the item is directly in the trash (it was archived independently, not as ;; part of a collection) (:archived_directly item) - ;; EITHER: (or ;; the item was archived from the root collection (nil? (:collection_id item)) ;; or the collection we'll restore to actually exists. (some? collection)) - ;; the collection we'll restore to is not archived (not (:archived collection)) - ;; we have perms on the collection (mi/can-write? (or collection root-collection))))))) diff --git a/src/metabase/collections/schema.clj b/src/metabase/collections/schema.clj index 639beeda545b..4e848278921a 100644 --- a/src/metabase/collections/schema.clj +++ b/src/metabase/collections/schema.clj @@ -42,19 +42,15 @@ [:authority_level {:optional true} [:maybe :string]] [:type {:optional true} ::CollectionType] [:is_remote_synced {:optional true} :boolean] - [:parent_id {:optional true} [:maybe [:or :string ms/PositiveInt]]] [:personal_owner_id {:optional true} ms/PositiveInt] [:is_personal {:optional true} :boolean] [:is_sample {:optional true} :boolean] - [:location [:maybe :string]] [:effective_location {:optional true} :string] [:effective_ancestors {:optional true} :map] - [:here {:optional true} [:set ::CollectionItemModel]] [:below {:optional true} [:sequential ::CollectionItemModel]] - [:git_sync_enabled {:optional true} :boolean]]) (mr/def ::LastEditInfo diff --git a/src/metabase/collections_rest/api.clj b/src/metabase/collections_rest/api.clj index 978ce3f09090..6e60ec49e712 100644 --- a/src/metabase/collections_rest/api.clj +++ b/src/metabase/collections_rest/api.clj @@ -1501,10 +1501,8 @@ (api/check-403 (perms/set-has-full-permissions-for-set? @api/*current-user-permissions-set* (collection/perms-for-moving collection-before-update new-parent))) - (api/check (not (collection/shared-tenant-collection? new-parent))) - ;; ok, we're good to move! (collection/move-collection! collection-before-update new-location (collection/moving-into-remote-synced? (collection/location-path->parent-id orig-location) @@ -1519,7 +1517,6 @@ (collection/archive-or-unarchive-collection! collection-before-update (select-keys collection-updates [:parent_id :archived])) - (maybe-send-archived-notifications! {:collection-before-update collection-before-update :collection-updates collection-updates :actor @api/*current-user*}))) diff --git a/src/metabase/comments/api.clj b/src/metabase/comments/api.clj index 8d0fec4c3c88..591b7a4db97e 100644 --- a/src/metabase/comments/api.clj +++ b/src/metabase/comments/api.clj @@ -206,7 +206,6 @@ entity (-> (api/read-check (type->model (:target_type comment)) (:target_id comment)) (u/prog1 (api/check-400 (not (entity-archived? <>)) "Cannot edit comments on archived entities")))] - (when content ;; Cannot edit content of deleted comments (api/check-400 (not (:deleted_at comment)) @@ -214,16 +213,13 @@ ;; Only creator or admin can edit comment content (api/check-403 (or (= (:creator_id comment) api/*current-user-id*) (:is_superuser @api/*current-user*)))) - (when (some? is_resolved) ;; Anyone with write permission to target entity can resolve/unresolve (api/write-check entity)) - (when-let [updates (-> {:content content :is_resolved is_resolved} u/remove-nils not-empty)] (t2/update! :model/Comment comment-id updates)) - (let [updated-comment (-> (t2/select-one :model/Comment :id comment-id) (t2/hydrate :creator :reactions))] (events/publish-event! :event/comment-update @@ -240,23 +236,18 @@ [{:keys [comment-id]} :- [:map [:comment-id ms/PositiveInt]] _query-params] (let [comment (api/check-404 (t2/select-one :model/Comment :id comment-id))] - (-> (api/read-check (type->model (:target_type comment)) (:target_id comment)) (u/prog1 (api/check-400 (not (entity-archived? <>)) "Cannot delete comments on archived entities"))) - ;; Only creator or admin can delete comments (api/check-403 (or (= (:creator_id comment) api/*current-user-id*) (:is_superuser @api/*current-user*))) (api/check-400 (not (:deleted_at comment)) "Comment is already deleted") - ;; Soft delete the comment (t2/update! :model/Comment comment-id {:deleted_at [:now]}) - (events/publish-event! :event/comment-delete {:object comment :user-id api/*current-user-id*}) - ;; Return 204 No Content api/generic-204-no-content)) @@ -272,11 +263,9 @@ (let [comment (api/check-404 (t2/select-one :model/Comment :id comment-id))] (api/check-400 (not (:deleted_at comment)) "Cannot react to deleted comments") - (-> (api/read-check (type->model (:target_type comment)) (:target_id comment)) (u/prog1 (api/check-400 (not (entity-archived? <>)) "Cannot react to comments on archived entities"))) - (comment-reaction/toggle-reaction comment-id api/*current-user-id* emoji))) ;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to @@ -288,7 +277,6 @@ [_route _query _body req] ;; no access in embedding context (api/check-404 (not (analytics/embedding-context? (get-in req [:headers "x-metabase-client"])))) - (let [clauses (user/filter-clauses {:limit (request/limit) :offset (request/offset)})] ;; returns nothing while we're trying to figure out how do we deal with sandboxes and tenants etc diff --git a/src/metabase/comments/models/comment_reaction.clj b/src/metabase/comments/models/comment_reaction.clj index 5837af7ffd12..5804d39dddfa 100644 --- a/src/metabase/comments/models/comment_reaction.clj +++ b/src/metabase/comments/models/comment_reaction.clj @@ -77,7 +77,6 @@ (if (= (:id user) current-user-id) (into [user] acc) (conj (or acc []) user)))] - (reduce (fn [acc reaction] (let [user (format-user (:user reaction))] (update-in acc [(:comment_id reaction) (:emoji reaction)] first-or-last user))) diff --git a/src/metabase/dashboards/models/dashboard.clj b/src/metabase/dashboards/models/dashboard.clj index 2873ebe7ed28..b9902decab26 100644 --- a/src/metabase/dashboards/models/dashboard.clj +++ b/src/metabase/dashboards/models/dashboard.clj @@ -98,7 +98,6 @@ dashboard (lib/normalize ::dashboards.schema/dashboard dashboard) changes (lib/normalize ::dashboards.schema/dashboard changes)] (collection/check-allowed-content :model/Dashboard (:collection_id changes)) - (u/prog1 (maybe-populate-initially-published-at dashboard) (params/assert-valid-parameters dashboard) (when (:parameters changes) @@ -168,7 +167,6 @@ ;; show it if: ;; - the card isn't archived [:= :card.archived false] - ;; - the card is archived BUT it's a dashboard question that wasn't archived by itself [:and [:not= :card.dashboard_id nil] diff --git a/src/metabase/dashboards/models/dashboard_card.clj b/src/metabase/dashboards/models/dashboard_card.clj index 4e0d3e67cf03..40ede1581217 100644 --- a/src/metabase/dashboards/models/dashboard_card.clj +++ b/src/metabase/dashboards/models/dashboard_card.clj @@ -215,7 +215,6 @@ (throw (ex-info "Cards with 'document_id' cannot be added to dashboards" {:status-code 400 :in-report-card-ids (map :id in-report-cards)})))))) - (let [dashboard-card-ids (t2/insert-returning-pks! :model/DashboardCard (for [dashcard dashboard-cards] @@ -239,14 +238,12 @@ orphaned-param-ids (set (mapcat :inline_parameters cards-being-deleted)) ;; Get dashboard IDs (should all be the same, but let's be safe) dashboard-ids (set (map :dashboard_id cards-being-deleted))] - (when (and (seq orphaned-param-ids) (= 1 (count dashboard-ids))) (let [dashboard-id (first dashboard-ids) dashboard (t2/select-one :model/Dashboard :id dashboard-id) current-params (:parameters dashboard) cleaned-params (filterv #(not (contains? orphaned-param-ids (:id %))) current-params)] - (when (not= (count current-params) (count cleaned-params)) (t2/update! :model/Dashboard dashboard-id {:parameters cleaned-params}) (count orphaned-param-ids))))))) @@ -258,7 +255,6 @@ (t2/with-transaction [_conn] ;; Clean up inline parameters before deletion (since we need to read the cards first) (cleanup-orphaned-inline-parameters! dashboard-card-ids) - ;; Delete the cards (t2/delete! :model/PulseCard :dashboard_card_id [:in dashboard-card-ids]) (t2/delete! :model/DashboardCard :id [:in dashboard-card-ids]))) diff --git a/src/metabase/dashboards/models/dashboard_tab.clj b/src/metabase/dashboards/models/dashboard_tab.clj index e8ddb306c0c4..d78806fb6355 100644 --- a/src/metabase/dashboards/models/dashboard_tab.clj +++ b/src/metabase/dashboards/models/dashboard_tab.clj @@ -90,7 +90,6 @@ (let [current-tab (get id->current-tab (:id new-tab))] (not= (select-keys current-tab update-ks) (select-keys new-tab update-ks)))) - new-tabs)] (doseq [tab to-update-tabs] (t2/update! :model/DashboardTab (:id tab) (select-keys tab update-ks))) diff --git a/src/metabase/dashboards_rest/api.clj b/src/metabase/dashboards_rest/api.clj index 3ce11cfb7c3a..2735ca3ad341 100644 --- a/src/metabase/dashboards_rest/api.clj +++ b/src/metabase/dashboards_rest/api.clj @@ -984,7 +984,6 @@ dash-updates (api/updates-with-archived-directly current-dash dash-updates)] (collection/check-allowed-to-change-collection current-dash dash-updates) (check-allowed-to-change-embedding current-dash dash-updates) - (api/check-500 (do (t2/with-transaction [_conn] @@ -1053,7 +1052,6 @@ (merge (select-keys tabs-changes-stats [:created-tab-ids :deleted-tab-ids :total-num-tabs]) (select-keys dashcards-changes-stats [:created-dashcards :deleted-dashcards]))))) - (collections/check-for-remote-sync-update current-dash)) true)) (let [dashboard (t2/select-one :model/Dashboard id)] diff --git a/src/metabase/documents/api/document.clj b/src/metabase/documents/api/document.clj index 668e5316343a..7b18cc7d0e11 100644 --- a/src/metabase/documents/api/document.clj +++ b/src/metabase/documents/api/document.clj @@ -219,7 +219,6 @@ (api/write-check existing-document) (when (api/column-will-change? :collection_id existing-document body) (m.document/validate-collection-move-permissions (:collection_id existing-document) collection_id)) - ;; Handle archiving logic (let [document-updates (dissoc (api/updates-with-archived-directly existing-document body) :cards)] (t2/with-transaction [_conn] diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index 0b9a9366225a..75007b66ab85 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -440,7 +440,6 @@ ;; Any options for `:select` types (s/optional-key :options) {s/Keyword s/Str}} - (complement (every-pred #(contains? % :default) #(contains? % :placeholder))) "connection details that does not have both default and placeholder")) @@ -545,30 +544,23 @@ ;; Not to be confused with Metabase's notion of foreign key columns. Those are user definable and power eg. ;; implicit joins. :metadata/key-constraints - ;; Does this database support nested fields for any and every field except primary key (e.g. Mongo)? :nested-fields - ;; Does this database support nested fields but only for certain field types (e.g. Postgres and JSON / JSONB columns)? :nested-field-columns - ;; Does this driver support setting a timezone for the query? :set-timezone - ;; Does the driver support *basic* aggregations like `:count` and `:sum`? (Currently, everything besides standard ;; deviation is considered \"basic\"; only GA doesn't support this). ;; ;; DEFAULTS TO TRUE. :basic-aggregations - ;; Does this driver support standard deviation and variance aggregations? Note that if variance is not supported ;; directly, you can calculate it manually by taking the square of the standard deviation. See the MongoDB driver ;; for example. :standard-deviation-aggregations - ;; Does this driver support expressions (e.g. adding the values of 2 columns together)? :expressions - ;; Does this driver support parameter substitution in native queries, where parameter expressions are replaced ;; with a single value? e.g. ;; @@ -576,18 +568,14 @@ ;; -> ;; SELECT * FROM table WHERE field = 1 :native-parameters - ;; Does the driver support using expressions inside aggregations? e.g. something like \"sum(x) + count(y)\" or ;; \"avg(x + y)\" :expression-aggregations - ;; Does the driver support expressions consisting of a single literal value like `1`, `\"hello\"`, and `false`. :expression-literals - ;; Does the driver support using a query as the `:source-query` of another MBQL query? Examples are CTEs or ;; subselects in SQL queries. :nested-queries - ;; Does this driver support native template tag parameters of type `:card`, e.g. in a native query like ;; ;; SELECT * FROM {{card}} @@ -597,15 +585,12 @@ ;; By default, this is true for drivers that support `:native-parameters` and `:nested-queries`, but drivers can opt ;; out if they do not support Card ID template tag parameters. :native-parameter-card-reference - ;; Does the driver support persisting models :persist-models ;; Is persisting enabled? :persist-models-enabled - ;; Does the driver support binning as specified by the `binning-strategy` clause? :binning - ;; Does this driver not let you specify whether or not our string search filter clauses (`:contains`, ;; `:starts-with`, and `:ends-with`, collectively the equivalent of SQL `LIKE`) are case-sensitive or not? This ;; informs whether we should present you with the 'Case Sensitive' checkbox in the UI. At the time of this writing @@ -613,238 +598,170 @@ ;; ;; DEFAULTS TO TRUE. :case-sensitivity-string-filter-options - ;; Implicit joins require :left-join (only) to work. :left-join :right-join :inner-join :full-join - :regex - ;; Added in 57.x; whether the driver in question supports lookaheads and lookbehinds in regular expressions; by ;; default this is true if the driver supports `:regex` but can be disabled for drivers where this is not true, ;; like BigQuery. :regex/lookaheads-and-lookbehinds - ;; Does the driver support advanced math expressions such as log, power, ... :advanced-math-expressions - ;; Does the driver support percentile calculations (including median) :percentile-aggregations - ;; Does the driver support date extraction functions? (i.e get year component of a datetime column) ;; DEFAULTS TO TRUE :temporal-extract - ;; Does the driver support doing math with datetime? (i.e Adding 1 year to a datetime column) ;; DEFAULTS TO TRUE :date-arithmetics - ;; Does the driver support the :now function :now - ;; Does the driver support converting timezone? ;; DEFAULTS TO FALSE :convert-timezone - ;; Does the driver support :datetime-diff functions :datetime-diff - ;; Does the driver support experimental "writeback" actions like "delete this row" or "insert a new row" from 44+? :actions - ;; Does the driver support storing table privileges in the application database for the current user? :table-privileges - ;; Does the driver support uploading files :uploads - ;; Does the driver support schemas (aka namespaces) for tables ;; DEFAULTS TO TRUE :schemas - ;; Does the driver support multi-level-schema for e.g. multicatalog support in databricks :multi-level-schema - ;; Does the driver support table renaming :rename - ;; Does the driver support atomic multi-table renaming :atomic-renames - ;; Does the driver support CREATE OR REPLACE TABLE syntax :create-or-replace-table - ;; Does the driver support custom writeback actions. Drivers that support this must ;; implement [[execute-write-query!]] :actions/custom - ;; Does the driver support editing data within database tables. :actions/data-editing - ;; Does changing the JVM timezone allow producing correct results? (See #27876 for details.) :test/jvm-timezone-setting - ;; Does the driver support connection impersonation (i.e. overriding the role used for individual queries)? :connection-impersonation - ;; Does the driver require specifying the default connection role for connection impersonation to work? :connection-impersonation-requires-role - ;; Does the driver require specifying a collection (table) for native queries? (mongo) :native-requires-specified-collection - ;; Index sync is turned off across the application as it is not used ATM. ;; Does the driver support column(s) support storing index info :index-info - ;; Does the driver support a faster `sync-fks` step by fetching all FK metadata in a single collection? ;; if so, `metabase.driver/describe-fks` must be implemented instead of `metabase.driver/describe-table-fks` :describe-fks - ;; Does the driver support a faster `sync-fields` step by fetching all FK metadata in a single collection? ;; if so, `metabase.driver/describe-fields` must be implemented instead of `metabase.driver/describe-table` :describe-fields - ;; Does the driver support a faster `sync-indexes` step by fetching all index metadata in a single collection? ;; If true, `metabase.driver/describe-indexes` must be implemented instead of `metabase.driver/describe-table-indexes` :describe-indexes - ;; Does the driver support automatically adding a primary key column to a table for uploads? ;; If so, Metabase will add an auto-incrementing primary key column called `_mb_row_id` for any table created or ;; updated with CSV uploads, and ignore any `_mb_row_id` column in the CSV file. ;; DEFAULTS TO TRUE :upload-with-auto-pk - ;; Does the driver support fingerprint the fields. Default is true :fingerprint - ;; Does a connection to this driver correspond to a single database (false), or to multiple databases (true)? ;; Default is false; ie. a single database. This is common for classic relational DBs and some cloud databases. ;; Some have access to many databases from one connection; eg. Athena connects to an S3 bucket which might have ;; many databases in it. :connection/multiple-databases - ;; Does the driver support identifiers for tables and columns that contain spaces. Defaults to `false`. :identifiers-with-spaces - ;; Does this driver support UUID type :uuid-type - ;; Does this driver support splitting strings and extracting a part? :split-part - ;; Does this driver support collation settings on text fields? :collate - ;; True if this driver requires `:temporal-unit :default` on all temporal field refs, even if no temporal ;; bucketing was specified in the query. ;; Generally false, but a few time-series based analytics databases (eg. Druid) require it. :temporal/requires-default-unit - ;; Does this driver support window functions like cumulative count and cumulative sum? (default: false) :window-functions/cumulative - ;; Does this driver support the new `:offset` MBQL clause added in 50? (i.e. SQL `lag` and `lead` or equivalent ;; functions) :window-functions/offset - ;; Does this driver support parameterized sql, eg. in prepared statements? :parameterized-sql - ;; Does this driver support the :distinct-where function? :distinct-where - ;; Does this driver support sandboxing with saved questions? :saved-question-sandboxing - ;; Does this driver support casting text and floats to integers? (`integer()` custom expression function) :expressions/integer - ;; Does this driver support casting values to text? (`text()` custom expression function) :expressions/text - ;; Does this driver support casting text to dates? (`date()` custom expression function) :expressions/date - ;; Does this driver support casting text to datetimes?? (`datetime()` custom expression function) :expressions/datetime - ;; Does this driver support casting text to floats? (`float()` custom expression function) :expressions/float - ;; Does this driver support returning the current date? (`today()` custom expression function) :expressions/today - ;; Does this driver support "temporal-unit" template tags in native queries? :native-temporal-units - ;; Does this driver support creating tables on their own without adding data? :test/create-table-without-data - ;; Does this driver support transforms with a table as the target? :transforms/table - ;; Does this driver support executing python transforms? :transforms/python - ;; Does this driver support calculating dependencies of native queries? :dependencies/native - ;; Does this driver properly support the table-exists? method for checking table existence? :metadata/table-existence-check - ;; Whether the driver supports loading dynamic test datasets on each test run. Eg. datasets with names like ;; `checkins:4-per-minute` are created dynamically in each test run. This should be truthy for every driver we test ;; against except for Athena and Databricks which currently require test data to be loaded separately. :test/dynamic-dataset-loading - ;; Some DBs allow you to connect to a DB that doesn't exist by creating it for you. ;; This is to allow such DBs to opt out of tests that rely on not being able to connect to non-existent DBs. :test/creates-db-on-connect - ;; For some cloud DBs the test database is never created, and can't or shouldn't be destroyed. ;; This is to allow avoiding destroying the test DBs of such cloud DBs. :test/cannot-destroy-db - ;; There are drivers that support uuids in queries, but not in create table as eg. Athena. :test/uuids-in-create-table-statements - ;; Use fake sync for slow drivers (e.g., Redshift). When enabled, the test infrastructure directly inserts ;; Table/Field rows from the dbdef instead of calling sync-database!, which can take ~10 minutes for Redshift. ;; Generally should be enabled for any driver where sync-database! takes longer than a few seconds. :test/use-fake-sync - ;; Does this driver support Metabase's database routing feature? :database-routing - ;; Does this driver support replication? :database-replication - ;; whether this driver supports checking table writeable permissions :metadata/table-writable-check - ;; Does this driver support creating a java.sql.Statement via a Connection? :jdbc/statements - ;; Can `Statement.setQueryTimeout` be called safely on this driver's statements? Defaults to true; set to ;; false for drivers where calling it poisons the underlying session (e.g. SparkSQL, where the call closes the ;; Thrift transport on the server side, causing subsequent statement close() to throw). :jdbc/set-query-timeout - ;; Does this driver provide :database-default on (describe-fields) or (describe-table) :describe-default-expr - ;; Does this driver provide :database-is-nullable on (describe-fields) or (describe-table) :describe-is-nullable - ;; Does this driver provide :database-is-generated on (describe-fields) or (describe-table) :describe-is-generated - ;; Does this driver support the workspace feature :workspace - ;; Does this driver support table references in native queries -- for example, "select * from {{table}}" where ;; `{{table}}` gets replaced by a reference to a table. :parameters/table-reference}) diff --git a/src/metabase/driver/common/parameters/dates.clj b/src/metabase/driver/common/parameters/dates.clj index 53002ec47a41..27515e47714e 100644 --- a/src/metabase/driver/common/parameters/dates.clj +++ b/src/metabase/driver/common/parameters/dates.clj @@ -54,7 +54,6 @@ :unit :day})) :filter (fn [_ field-clause] [:= (with-temporal-unit-if-field field-clause :day) [:relative-datetime :current]])} - {:parser #(= % "yesterday") :range (fn [_ dt] (let [dt-res (t/local-date dt)] @@ -63,7 +62,6 @@ :unit :day})) :filter (fn [_ field-clause] [:= (with-temporal-unit-if-field field-clause :day) [:relative-datetime -1 :day]])} - ;; Adding a tilde (~) at the end of a pasts filter means we should include the current day/etc. ;; e.g. past30days = past 30 days, not including partial data for today ({:include-current false}) ;; past30days~ = past 30 days, *including* partial data for today ({:include-current true}). @@ -93,7 +91,6 @@ (- int-value) (keyword unit) {:include-current (#'qp.parameters.dates/include-current? relative-suffix)}]))} - {:parser (#'qp.parameters.dates/regex->parser (re-pattern (str #"next([0-9]+)" @#'qp.parameters.dates/temporal-units-regex #"s" @#'qp.parameters.dates/relative-suffix-regex)) [:int-value :unit :relative-suffix :int-value-1 :unit-1]) :range (fn [{:keys [unit int-value unit-range to-period relative-suffix unit-1 int-value-1]} dt] @@ -115,7 +112,6 @@ int-value (keyword unit) {:include-current (#'qp.parameters.dates/include-current? relative-suffix)}]))} - {:parser (#'qp.parameters.dates/regex->parser (re-pattern (str #"last" @#'qp.parameters.dates/temporal-units-regex)) [:unit]) :range (fn [{:keys [unit unit-range to-period]} dt] @@ -123,7 +119,6 @@ (unit-range last-unit last-unit))) :filter (fn [{:keys [unit]} field-clause] [:time-interval field-clause :last (keyword unit)])} - {:parser (#'qp.parameters.dates/regex->parser (re-pattern (str #"this" @#'qp.parameters.dates/temporal-units-regex)) [:unit]) :range (fn [{:keys [unit unit-range]} dt] diff --git a/src/metabase/driver/h2.clj b/src/metabase/driver/h2.clj index 567604b95881..f9a9c966794e 100644 --- a/src/metabase/driver/h2.clj +++ b/src/metabase/driver/h2.clj @@ -456,10 +456,8 @@ [:case [:and [:< x y] [:> (time-zoned-extract :day x) (time-zoned-extract :day y)]] -1 - [:and [:> x y] [:< (time-zoned-extract :day x) (time-zoned-extract :day y)]] 1 - :else 0])) diff --git a/src/metabase/driver/mysql.clj b/src/metabase/driver/mysql.clj index c21f7d38f7bd..3f3002061077 100644 --- a/src/metabase/driver/mysql.clj +++ b/src/metabase/driver/mysql.clj @@ -421,7 +421,6 @@ ;; non-positive position [:< pos 1] "" - ;; position greater than number of parts [:> pos [:+ 1 @@ -431,7 +430,6 @@ [:length [:replace text div ""]]] [:length div]]]]] "" - ;; This needs some explanation. ;; The inner substring_index returns the string up to the `pos` instance of `div` ;; The outer substring_index returns the string from the last instance of `div` to the end @@ -1162,14 +1160,12 @@ [[:= :c.column_key [:inline "PRI"]] :pk?] [[:= :is_nullable [:inline "YES"]] :database-is-nullable] [[:if [:= [:lower :column_default] [:inline "null"]] nil :column_default] :database-default] - [[:and ;; mariadb [:!= :generation_expression nil] ;; mysql [:<> :generation_expression ""]] :database-is-generated] - [[:nullif :c.column_comment [:inline ""]] :field-comment]] :from [[:information_schema.columns :c]] :where diff --git a/src/metabase/driver/postgres.clj b/src/metabase/driver/postgres.clj index efb9a353c4dc..9847b0fe53b7 100644 --- a/src/metabase/driver/postgres.clj +++ b/src/metabase/driver/postgres.clj @@ -224,7 +224,6 @@ driver.common/ssh-tunnel-preferences]} driver.common/advanced-options-start driver.common/json-unfolding - (assoc driver.common/additional-options :placeholder "prepareThreshold=0") driver.common/default-advanced-options] @@ -687,7 +686,6 @@ [:case [:< position 1] "" - :else [:split_part (sql.qp/->honeysql driver text) (sql.qp/->honeysql driver divider) position]])) @@ -960,7 +958,6 @@ (m/assoc-some :sslcert (driver-api/secret-value-as-file! :postgres db-details "ssl-client-cert")) ;; Pass an empty string as password if none is provided; otherwise the driver will prompt for one (assoc :sslpassword (or (driver-api/secret-value-as-string :postgres db-details "ssl-key-password") "")) - (as-> params ;; from outer cond-> (dissoc params :ssl-root-cert :ssl-root-cert-options :ssl-client-key :ssl-client-cert :ssl-key-password :ssl-use-client-auth) diff --git a/src/metabase/driver/sql/query_processor.clj b/src/metabase/driver/sql/query_processor.clj index 537789eb9bc8..c694302739af 100644 --- a/src/metabase/driver/sql/query_processor.clj +++ b/src/metabase/driver/sql/query_processor.clj @@ -2147,7 +2147,6 @@ (u/prog1 (apply-clauses driver {} inner-query) (log/debugf "\nHoneySQL Form: %s\n%s" (u/emoji "🍯") (u/pprint-to-str 'cyan <>)) (driver-api/debug> (list '🍯 <>))))) - (let [metadata-provider (driver-api/metadata-provider) database-id (if (:type query) (:database query) diff --git a/src/metabase/driver/sql_jdbc/sync.clj b/src/metabase/driver/sql_jdbc/sync.clj index 69688d0e64ff..f0a2b041a7a6 100644 --- a/src/metabase/driver/sql_jdbc/sync.clj +++ b/src/metabase/driver/sql_jdbc/sync.clj @@ -26,7 +26,6 @@ fallback-metadata-query filtered-syncable-schemas have-select-privilege?] - [sql-jdbc.describe-table add-table-pks database-type->base-type-or-warn @@ -44,11 +43,9 @@ describe-table-indexes get-catalogs pattern-based-database-type->base-type] - [sql-jdbc.describe-database describe-database fast-active-tables post-filtered-active-tables] - [sql-jdbc.dbms-version dbms-version]) diff --git a/src/metabase/driver/sql_jdbc/sync/describe_database.clj b/src/metabase/driver/sql_jdbc/sync/describe_database.clj index cddc1627bdae..e346c7f0ba40 100644 --- a/src/metabase/driver/sql_jdbc/sync/describe_database.clj +++ b/src/metabase/driver/sql_jdbc/sync/describe_database.clj @@ -106,7 +106,6 @@ (log/infof "%s: SELECT privileges confirmed" (pr-table table-schema table-name)) true (catch Throwable e - (let [;; Let's try to ensure the connection is not just open but also valid. ;; Snowflake closes the connection but doesn't set it as closed in the object, ;; so we must explicitly check if it's valid so that subsequent calls to [[sql-jdbc.execute/try-ensure-open-conn!]] @@ -114,11 +113,9 @@ is-open (sql-jdbc.execute/is-conn-open? conn :check-valid? true) allow? (driver/query-canceled? driver e)] - (if allow? (log/infof "%s: Assuming SELECT privileges: caught timeout exception" (pr-table table-schema table-name)) (log/debugf e "%s: Assuming no SELECT privileges: caught exception" (pr-table table-schema table-name))) - ;; if the connection was closed this will throw an error and fail the sync loop so we prevent this error from ;; affecting anything higher (try (when-not (.getAutoCommit conn) diff --git a/src/metabase/driver/util.clj b/src/metabase/driver/util.clj index 1221efa86bb0..aa83b006eae2 100644 --- a/src/metabase/driver/util.clj +++ b/src/metabase/driver/util.clj @@ -679,12 +679,10 @@ (.init ^KeyStore (cast KeyStore nil)))] (doseq [cert certs] (.setCertificateEntry keystore (dn-for-cert cert) cert)) - (doseq [^X509TrustManager trust-mgr (.getTrustManagers base-trust-manager-factory)] (when (instance? X509TrustManager trust-mgr) (doseq [issuer (.getAcceptedIssuers trust-mgr)] (.setCertificateEntry keystore (dn-for-cert issuer) issuer)))) - keystore)) (defn- key-managers [private-key password own-cert] diff --git a/src/metabase/events/impl.clj b/src/metabase/events/impl.clj index cc9c1b75e955..fe75cdb3e61d 100644 --- a/src/metabase/events/impl.clj +++ b/src/metabase/events/impl.clj @@ -70,16 +70,13 @@ {:arglists '([topic event]) :defmethod-arities #{2} :dispatch-value-spec ::publish-event-dispatch-value} - :combo (methodical/do-method-combination) - ;; work around https://github.com/camsaul/methodical/issues/97 :dispatcher (u.methodical.unsorted-dispatcher/unsorted-dispatcher (fn dispatch-fn [topic _event] (keyword topic))) - ;; work around https://github.com/camsaul/methodical/issues/98 :cache (u.methodical.null-cache/null-cache)) diff --git a/src/metabase/interestingness/chart/stats.clj b/src/metabase/interestingness/chart/stats.clj index f9387861a8cb..7b29972f04bf 100644 --- a/src/metabase/interestingness/chart/stats.clj +++ b/src/metabase/interestingness/chart/stats.clj @@ -107,7 +107,6 @@ [{:keys [display_type series]}] (or (get explicit-display-types display_type) - (when-let [[_ first-series] (first series)] (let [x-type (get-in first-series [:x :type]) x-values (:x_values first-series) @@ -134,7 +133,6 @@ :scatter :else :categorical))) - :categorical)) (mu/defn compute-chart-stats :- ::stats.types/chart-stats diff --git a/src/metabase/legacy_mbql/schema.cljc b/src/metabase/legacy_mbql/schema.cljc index 50a95951d0c6..56280e1400f0 100644 --- a/src/metabase/legacy_mbql/schema.cljc +++ b/src/metabase/legacy_mbql/schema.cljc @@ -1597,7 +1597,6 @@ [:type [:= {:decode/normalize helpers/normalize-keyword} :dimension]] [:dimension [:ref ::field]] [:alias {:optional true} :string] - [:widget-type {:default :category} [:ref @@ -1605,7 +1604,6 @@ "which type of widget the frontend should show for this Field Filter; this also affects which parameter types are allowed to be specified for it."} ::WidgetType]] - [:options {:optional true :description "optional map to be appended to filter clause"} @@ -1918,9 +1916,7 @@ :description "*What* to JOIN. Self-joins can be done by using the same `:source-table` as in the query where this is specified. YOU MUST SUPPLY EITHER `:source-table` OR `:source-query`, BUT NOT BOTH!"} [:ref ::SourceTable]] - [:source-query {:optional true} [:ref ::SourceQuery]] - [:condition {:description "The condition on which to JOIN. Can be anything that is a valid `:filter` clause. For automatically-generated @@ -1928,14 +1924,12 @@ [:= [:field {:join-alias }]]"} [:ref ::Filter]] - [:strategy {:optional true :description "Defaults to `:left-join`; used for all automatically-generated JOINs Driver implementations: this is guaranteed to be present after pre-processing."} [:ref ::lib.schema.join/strategy]] - [:fields {:optional true :description @@ -1954,7 +1948,6 @@ Driver implementations: you can ignore this clause. Relevant fields will be added to top-level `:fields` clause with appropriate aliases."} [:ref ::JoinFields]] - [:alias {:optional true :description @@ -1964,7 +1957,6 @@ Driver implementations: This is guaranteed to be present after pre-processing."} ::lib.schema.join/alias] - [:fk-field-id {:optional true :description "Mostly used only internally. When a join is implicitly generated via a `:field` clause with @@ -1974,7 +1966,6 @@ Don't set this information yourself. It will have no effect."} [:maybe ::lib.schema.id/field]] - [:source-metadata {:optional true :description "Metadata about the source query being used, if pulled in from a Card via the @@ -2202,13 +2193,11 @@ [:ref ::CheckQueryDoesNotHaveSourceMetadata] [:map [:database {:optional true} ::DatabaseID] - [:type [:enum {:decode/normalize helpers/normalize-keyword :description "Type of query. `:query` = MBQL; `:native` = native."} :query :native]] - [:native {:optional true} [:ref ::NativeQuery]] [:query {:optional true} [:ref ::MBQLQuery]] [:parameters {:optional true} [:maybe [:ref ::lib.schema.parameter/parameters]]] diff --git a/src/metabase/lib/aggregation.cljc b/src/metabase/lib/aggregation.cljc index 0627cebc96be..a8bbe29f85e6 100644 --- a/src/metabase/lib/aggregation.cljc +++ b/src/metabase/lib/aggregation.cljc @@ -96,7 +96,6 @@ (lib.metadata.calculation/metadata query stage-number aggregation) {:lib/source :source/aggregations :lib/source-uuid (:lib/uuid (second aggregation))} - (when base-type {:base-type base-type}) (when effective-type diff --git a/src/metabase/lib/core.cljc b/src/metabase/lib/core.cljc index d6965d4a8892..e98f047f9682 100644 --- a/src/metabase/lib/core.cljc +++ b/src/metabase/lib/core.cljc @@ -1403,7 +1403,6 @@ [lib.equality find-column-for-legacy-ref find-matching-column] - [lib.extraction column-extractions extract diff --git a/src/metabase/lib/display_name.cljc b/src/metabase/lib/display_name.cljc index eeedc2b0dc2b..664e6d882711 100644 --- a/src/metabase/lib/display_name.cljc +++ b/src/metabase/lib/display_name.cljc @@ -176,7 +176,6 @@ (letfn [(parse-inner [string] ;; Recursively parse the inner part which may have more patterns (parse-column-display-name-parts string aggregation-patterns filter-patterns conjunctions)) - (parse-join-alias [join-alias] ;; Parse join alias which may be an implicit join like "People - Product" (if-let [{:keys [table fk-field]} (try-parse-implicit-join-to-parts join-alias)] @@ -186,7 +185,6 @@ (into (parse-inner fk-field))) ;; Simple join alias, just translate it (parse-inner join-alias)))] - (or ;; First try compound filter (e.g. "X is empty or Y is empty") ;; Must be before individual filter matching so each clause is parsed independently. @@ -197,14 +195,12 @@ [(static-part %1)] (parse-inner %1)) segments (range)))) - ;; Then try aggregation patterns (most specific, wraps other patterns) (when-let [{:keys [prefix suffix inner]} (try-parse-aggregation-to-parts display-name aggregation-patterns)] (-> [] (cond-> (seq prefix) (conj (static-part prefix))) (into (parse-inner inner)) (cond-> (seq suffix) (conj (static-part suffix))))) - ;; Then try filter patterns (column + operator + values) ;; Must be before join/colon parsing since filter text may contain ": " or " → " (when-let [{:keys [column prefix separator remainder]} @@ -214,14 +210,12 @@ (into (parse-inner column)) (cond-> (seq separator) (conj (static-part separator))) (cond-> (seq remainder) (conj (static-part remainder))))) - ;; Then try join pattern (when-let [{:keys [join-alias column]} (try-parse-join-to-parts display-name)] (-> [] (into (parse-join-alias join-alias)) (conj (static-part join-display-name-separator)) (into (parse-inner column)))) - ;; Then try colon suffix (temporal bucket or binning) ;; The suffix is static because it's a unit name (Month, Day, etc.) or binning label (when-let [{:keys [column suffix]} (try-parse-colon-suffix-to-parts display-name)] @@ -229,6 +223,5 @@ (into (parse-inner column)) (conj (static-part column-display-name-separator)) (conj (static-part suffix)))) - ;; Otherwise it's a plain column name - translatable [(translatable-part display-name)])))) diff --git a/src/metabase/lib/fe_util.cljc b/src/metabase/lib/fe_util.cljc index f55415e7ec5f..576abdb0280a 100644 --- a/src/metabase/lib/fe_util.cljc +++ b/src/metabase/lib/fe_util.cljc @@ -380,16 +380,12 @@ (:or ;; no arguments [(op :guard #{:is-null :not-null}) _ (col-ref :guard number-col?) & (args :len 0 :guard (every? number-arg? args))] - ;; multiple arguments, `:=` [(op :guard #{:= :in}) _ (col-ref :guard number-col?) & (args :guard (every? number-arg? args))] - ;; multiple arguments, `:!=` [(op :guard #{:!= :not-in}) _ (col-ref :guard number-col?) & (args :guard (every? number-arg? args))] - ;; exactly 1 argument [(op :guard #{:> :>= :< :<=}) _ (col-ref :guard number-col?) & (args :len 1 :guard (every? number-arg? args))] - ;; exactly 2 arguments [(op :guard #{:between}) _ (col-ref :guard number-col?) & (args :len 2 :guard (every? number-arg? args))]) {:operator ({:in :=, :not-in :!=} op op) @@ -525,7 +521,6 @@ (:or ;; exactly 1 argument [(op :guard #{:= :> :<}) _ (col-ref :guard date-col?) & (args :len 1 :guard (every? string? args))] - ;; exactly 2 arguments [(op :guard #{:between}) _ (col-ref :guard date-col?) & (args :len 2 :guard (every? string? args))]) (result op col-ref args) diff --git a/src/metabase/lib/field.cljc b/src/metabase/lib/field.cljc index 2176aedbbb9a..0cfb4f8b677f 100644 --- a/src/metabase/lib/field.cljc +++ b/src/metabase/lib/field.cljc @@ -587,7 +587,6 @@ :when field] [join field])) join-fields (lib.join/join-fields join)] - ;; Nothing to do if it's already selected, or if this join already has :fields :all. ;; Otherwise, append it to the list of fields. (if (or (= join-fields :all) diff --git a/src/metabase/lib/field/util.cljc b/src/metabase/lib/field/util.cljc index b805f7e43007..4707ba116453 100644 --- a/src/metabase/lib/field/util.cljc +++ b/src/metabase/lib/field/util.cljc @@ -124,7 +124,6 @@ ;; desired-column-alias is previous stage => source column alias in next stage :lib/source-column-alias ((some-fn :lib/desired-column-alias :lib/source-column-alias :name) col) :lib/source :source/previous-stage) - ;; Native sandboxes need special handling for any type coercions set on the sandboxed table's fields. ;; The columns first appear in the native stage, but we need to propagate the coercion metadata to the next stage ;; so it gets applied in MBQL. After that first propagation, it should always be removed to prevent @@ -135,7 +134,6 @@ (cond-> #_col (not (:qp/native-sandbox-column.propagate-coercion? col)) (dissoc :qp/native-sandbox-column.force-coercion-strategy)) - ;; ;; Remove `:lib/desired-column-alias`, which needs to be recalculated in the context ;; of what is returned by the current stage, to prevent any confusion; its value is likely wrong now and we diff --git a/src/metabase/lib/filter/desugar/jvm.clj b/src/metabase/lib/filter/desugar/jvm.clj index f870d03b4b41..d2fac076d399 100644 --- a/src/metabase/lib/filter/desugar/jvm.clj +++ b/src/metabase/lib/filter/desugar/jvm.clj @@ -43,7 +43,6 @@ 11. Optional `:port`, `/path`, `?query` or `#hash` 12. Anchor to the end" - ;;1 2 3 4 5 6 7 8 9 10 11 12 #"(?<=@|//|\.|^)(?!www\.)[^@\.:/?#]+\.(?:[^@\.:/?#]{1,3}\.)?[^@\.:/?#]+(?=[:/?#].*$|$)") diff --git a/src/metabase/lib/filter/update.cljc b/src/metabase/lib/filter/update.cljc index dc131298e986..d6e1be0b9fd5 100644 --- a/src/metabase/lib/filter/update.cljc +++ b/src/metabase/lib/filter/update.cljc @@ -163,7 +163,6 @@ (mr/def ::temporal-literal #?(:clj ::lib.schema.literal/temporal - :cljs [:or ::lib.schema.literal/temporal diff --git a/src/metabase/lib/native.cljc b/src/metabase/lib/native.cljc index b50402c16d43..7d5fe9e93781 100644 --- a/src/metabase/lib/native.cljc +++ b/src/metabase/lib/native.cljc @@ -405,7 +405,6 @@ (keep (fn [[tag-name {:keys [id] :as tag}]] (or (params-by-id id) (get-parameter-value query tag-name tag)))) - ttags)] (cond-> query (seq new-parameters) (assoc :parameters new-parameters)))) diff --git a/src/metabase/lib/schema/constraints.cljc b/src/metabase/lib/schema/constraints.cljc index 916dc2675087..3800c50e8c52 100644 --- a/src/metabase/lib/schema/constraints.cljc +++ b/src/metabase/lib/schema/constraints.cljc @@ -16,7 +16,6 @@ "Maximum number of results to allow for a query with aggregations. If `max-results-bare-rows` is unset, this applies to all queries"} nat-int?] - [:max-results-bare-rows {:optional true :description diff --git a/src/metabase/lib/schema/expression/conditional.cljc b/src/metabase/lib/schema/expression/conditional.cljc index 4b5d1394fce8..43b399c5e030 100644 --- a/src/metabase/lib/schema/expression/conditional.cljc +++ b/src/metabase/lib/schema/expression/conditional.cljc @@ -88,7 +88,6 @@ #_pred [:ref ::expression/boolean] #_expr [:ref ::expression/expression]]]] [:default [:? [:schema [:ref ::expression/expression]]]])]) - (defmethod expression/type-of-method tag [[_tag _opts pred-expr-pairs default]] (let [exprs (concat diff --git a/src/metabase/lib/schema/expression/temporal.cljc b/src/metabase/lib/schema/expression/temporal.cljc index 37348df3af03..1187f35f8490 100644 --- a/src/metabase/lib/schema/expression/temporal.cljc +++ b/src/metabase/lib/schema/expression/temporal.cljc @@ -110,7 +110,6 @@ (into [:enum {:decode/normalize normalize-datetime-mode}] datetime-string-modes)]]] [:schema [:ref ::expression/string]]] - ;; number modes [:cat [:merge @@ -118,7 +117,6 @@ [:map [:mode (into [:enum {:decode/normalize normalize-datetime-mode}] datetime-number-modes)]]] [:schema [:ref ::expression/number]]] - ;; binary modes [:cat [:merge diff --git a/src/metabase/lib/schema/middleware_options.cljc b/src/metabase/lib/schema/middleware_options.cljc index a0b24af859db..f236e92c4a1a 100644 --- a/src/metabase/lib/schema/middleware_options.cljc +++ b/src/metabase/lib/schema/middleware_options.cljc @@ -14,14 +14,12 @@ `metabase.query-processor.middleware.results-metadata`; default `false`. (Note: we may change the name of this column in the near future, to `result_metadata`, to fix inconsistencies in how we name things.)"} :boolean] - [:format-rows? {:optional true :description "Should we skip converting datetime types to ISO-8601 strings with appropriate timezone when post-processing results? Used by `metabase.query-processor.middleware.format-rows`default `false`."} :boolean] - [:disable-mbql->native? {:optional true :description @@ -29,14 +27,12 @@ you should set this yourself. This is only used by the `metabase.query-processor.preprocess/preprocess` function to get the fully pre-processed query without attempting to convert it to native."} :boolean] - [:disable-max-results? {:optional true :description "Disable applying a default limit on the query results. Handled in the `add-default-limit` middleware. If true, this will override the `:max-results` and `:max-results-bare-rows` values in `Constraints`."} :boolean] - [:userland-query? {:optional true :description @@ -44,7 +40,6 @@ certain userland-only middleware for such queries -- results are returned in a slightly different format, and QueryExecution entries are normally saved, unless you pass `:no-save` as the option."} [:maybe :boolean]] - [:add-default-userland-constraints? {:optional true :description @@ -52,7 +47,6 @@ although the functions that ultimately power most API endpoints tend to set this to `true`. See `add-constraints` middleware for more details."} [:maybe :boolean]] - [:process-viz-settings? {:optional true :description diff --git a/src/metabase/lib/schema/parameter.cljc b/src/metabase/lib/schema/parameter.cljc index 93645b4835f9..06dd1229baa9 100644 --- a/src/metabase/lib/schema/parameter.cljc +++ b/src/metabase/lib/schema/parameter.cljc @@ -79,10 +79,8 @@ :location/city :location/state :location/zip_code :location/country}} :date {:type :date, :allowed-for #{:date :date/single :date/all-options :id :category}} :boolean {:type :boolean, :allowed-for #{:boolean :id :category :boolean/=}} - ;; as far as I can tell this is basically just an alias for `:date`... I'm not sure what the difference is TBH :date/single {:type :date, :allowed-for #{:date :date/single :date/all-options :id :category}} - ;; everything else can't be used with raw value template tags -- they can only be used with Dashboard parameters ;; for MBQL queries or Field filters in native queries @@ -97,7 +95,6 @@ ;; See QUE2-326 for history. :id {:allowed-for #{:id}} :category {:allowed-for #{:category :number :text :date :boolean}} - ;; Like `:id` and `:category`, the `:location/*` types are primarily widget types. They don't really have a meaning ;; as a parameter type, so in an ideal world they wouldn't be allowed; however it seems like the FE still passed ;; these in as parameter type on occasion anyway. In this case the backend is just supposed to infer the actual @@ -110,23 +107,19 @@ :location/state {:allowed-for #{:location/state}} :location/zip_code {:allowed-for #{:location/zip_code}} ; TODO (Cam 8/12/25) -- should use `kebab-case` like literally every other type :location/country {:allowed-for #{:location/country}} - ;; date range types -- these match a range of dates :date/range {:type :date, :allowed-for #{:date/range :date/all-options}} :date/month-year {:type :date, :allowed-for #{:date/month-year :date/all-options}} :date/quarter-year {:type :date, :allowed-for #{:date/quarter-year :date/all-options}} :date/relative {:type :date, :allowed-for #{:date/relative :date/all-options}} - ;; Like `:id` and `:category` above, `:date/all-options` is primarily a widget type. It means that we should allow ;; any date option above. :date/all-options {:type :date, :allowed-for #{:date/all-options}} - ;; `:temporal-unit` is a specialized type of parameter, and specialized widget. In MBQL queries, it maps only to ;; breakout columns which have temporal bucketing set, and overrides the unit from the query. ;; The value for this type of parameter is one of the temporal units from [[metabase.lib.schema.temporal-bucketing]]. ;; TODO: Document how this works for native queries. :temporal-unit {:allowed-for #{:temporal-unit}} - ;; "operator" parameter types. :number/!= {:type :numeric, :operator :variadic, :allowed-for #{:number/!=}} :number/<= {:type :numeric, :operator :unary, :allowed-for #{:number/<= :number/between}} diff --git a/src/metabase/lib/types/constants.cljc b/src/metabase/lib/types/constants.cljc index acbe83338a9b..248efbc0066a 100644 --- a/src/metabase/lib/types/constants.cljc +++ b/src/metabase/lib/types/constants.cljc @@ -11,7 +11,6 @@ (reduce (fn [m typ] (doto m (gobj/set (name typ) typ))) #js {} (distinct (mapcat descendants [:type/* :Semantic/* :Relation/*])))) - ;; primary field types used for picking operators, etc (def ^:export key-number "JS-friendly access for the number type" ::number) (def ^:export key-string "JS-friendly access for the string type" ::string) @@ -25,12 +24,10 @@ (def ^:export key-json "JS-friendly access for the JSON type" ::json) (def ^:export key-xml "JS-friendly access for the JSON type" ::xml) (def ^:export key-structured "JS-friendly access for the structured type" ::structured) - ;; other types used for various purposes (def ^:export key-summable "JS-friendly access for the summable type" ::summable) (def ^:export key-scope "JS-friendly access for the scope type" ::scope) (def ^:export key-category "JS-friendly access for the category type" ::category) - (def ^:export key-unknown "JS-friendly access for the unknown type" ::unknown))) ;; NOTE: be sure not to create cycles using the "other" types diff --git a/src/metabase/lib/util.cljc b/src/metabase/lib/util.cljc index fbd7ebc676df..1f35db77546d 100644 --- a/src/metabase/lib/util.cljc +++ b/src/metabase/lib/util.cljc @@ -35,7 +35,6 @@ ;;; Cljs. They both work like [[clojure.core/format]], but since that doesn't exist in Cljs, you can use this instead. #?(:clj (p/import-vars [clojure.core format]) - :cljs (def format "Exactly like [[clojure.core/format]] but ClojureScript-friendly." gstring/format)) diff --git a/src/metabase/lib_metric/core.cljc b/src/metabase/lib_metric/core.cljc index 35ee813a3340..2b6a02f82223 100644 --- a/src/metabase/lib_metric/core.cljc +++ b/src/metabase/lib_metric/core.cljc @@ -79,7 +79,6 @@ add-projection-positions default-breakout-dimensions projectable-dimensions]) - :cljs (do (def remove-clause "See [[lib-metric.clause/remove-clause]]." lib-metric.clause/remove-clause) diff --git a/src/metabase/llm/context.clj b/src/metabase/llm/context.clj index c89cf42453e1..29cd8dbbd325 100644 --- a/src/metabase/llm/context.clj +++ b/src/metabase/llm/context.clj @@ -483,7 +483,6 @@ (mapv (fn [table] (update table :columns format-columns-for-response fk-targets-map)) tables-with-enriched-fps)] - (when (seq enriched-tables) {:ddl (format-schema-ddl enriched-tables) :tables response-tables}))))))) @@ -519,7 +518,6 @@ all-columns (mapcat :columns tables-with-columns) fk-targets-map (fetch-fk-targets all-columns)] - (mapv (fn [table] (update table :columns (fn [cols] diff --git a/src/metabase/logger/core.clj b/src/metabase/logger/core.clj index d3661f0a4802..1c29fc2e9756 100644 --- a/src/metabase/logger/core.clj +++ b/src/metabase/logger/core.clj @@ -158,9 +158,7 @@ (add-ns-logger! ns appender level additive)))] (.start appender) (.addAppender config appender) - (.updateLoggers (context)) - (reify AutoCloseable (close [_] (let [^AbstractConfiguration config (configuration)] diff --git a/src/metabase/metabot/agent/markdown_link_buffer.clj b/src/metabase/metabot/agent/markdown_link_buffer.clj index 99ae4e3b2896..e52022ae48b0 100644 --- a/src/metabase/metabot/agent/markdown_link_buffer.clj +++ b/src/metabase/metabot/agent/markdown_link_buffer.clj @@ -152,7 +152,6 @@ (vswap! charts assoc chart-id (get-structured-output (:result part))))) ;; Update state with new context (vswap! state with-context @queries @charts)) - ;; Process text parts through link buffer (if (= (:type part) :text) (let [[new-state processed-text] (step @state (:text part))] diff --git a/src/metabase/metabot/api/document.clj b/src/metabase/metabot/api/document.clj index d7e4fd639cc1..15dc9a6eff1d 100644 --- a/src/metabase/metabot/api/document.clj +++ b/src/metabase/metabot/api/document.clj @@ -89,7 +89,6 @@ "Create a new piece of content to insert into the document. Kept for backwards compatibility; now uses the native Clojure agent." [_route-params _query-params - {:keys [instructions references]} :- generate-content-body-schema] (let [metabot-id (metabot.config/resolve-dynamic-metabot-id nil)] (metabot.config/check-metabot-enabled! metabot-id) diff --git a/src/metabase/metabot/api/entity_analysis.clj b/src/metabase/metabot/api/entity_analysis.clj index dbaf155646c6..c289dfa684d0 100644 --- a/src/metabase/metabot/api/entity_analysis.clj +++ b/src/metabase/metabot/api/entity_analysis.clj @@ -26,7 +26,6 @@ [:name :string] [:description {:optional true} [:maybe :string]] [:timestamp :string]]]]]]] - (metabot.config/check-metabot-enabled!) (metabot.usage/check-metabase-managed-free-limit!) (let [chart-data {:image_base64 image_base64 diff --git a/src/metabase/metabot/context.clj b/src/metabase/metabot/context.clj index 594948dc0513..ea46d734dc0e 100644 --- a/src/metabase/metabot/context.clj +++ b/src/metabase/metabot/context.clj @@ -211,17 +211,14 @@ (when-let [query (query-for-sql-parsing item)] (when-let [tables (seq (database-tables-for-context {:query query}))] (assoc item :used_tables tables))) - ;; Handle MBQL/notebook queries (when-let [db-and-table-ids (mbql-source-table-ids item)] (when-let [tables (seq (mbql-source-tables-for-context db-and-table-ids))] (assoc item :used_tables tables))) - ;; Handle Python transforms (when-let [db-and-table-ids (python-transform-db-and-table-ids item)] (when-let [tables (seq (python-transform-tables-for-context db-and-table-ids))] (assoc item :used_tables tables))) - ;; Unknown item: return unchanged item)) user-viewing)] diff --git a/src/metabase/metabot/self/core.clj b/src/metabase/metabot/self/core.clj index 80ed47929851..7acb8d3e4473 100644 --- a/src/metabase/metabot/self/core.clj +++ b/src/metabase/metabot/self/core.clj @@ -498,7 +498,6 @@ ;; otherwise: do nothing nil) - (rf result chunk)))))) (def ^:private max-body-preview-chars diff --git a/src/metabase/metabot/self/openrouter.clj b/src/metabase/metabot/self/openrouter.clj index 767108c9fe45..30ab0a38bd38 100644 --- a/src/metabase/metabot/self/openrouter.clj +++ b/src/metabase/metabot/self/openrouter.clj @@ -192,7 +192,6 @@ ;; For new tool calls, the id comes from the chunk; for deltas ;; on the same tool, we keep current-id. chunk-id (or (:id tool-call) @current-id (core/mkid))] - (cond-> result ;; Emit :start on first chunk (and id (not @message-id)) (-> (rf {:type :start :messageId id}) diff --git a/src/metabase/metabot/tools/autogen_dashboard.clj b/src/metabase/metabot/tools/autogen_dashboard.clj index 0bd094518c8c..b5402fa42bbd 100644 --- a/src/metabase/metabot/tools/autogen_dashboard.clj +++ b/src/metabase/metabot/tools/autogen_dashboard.clj @@ -106,12 +106,10 @@ (let [memory (when memory-atom @memory-atom) {:keys [path]} (resolve-source source memory) response-message (str (random-response-message) "\n\nLet me know if you need anything else.")] - ;; Note: The actual automagic analysis happens on navigation to the /auto/dashboard/... URL ;; The frontend handles rendering the X-ray dashboard (log/info "Autogenerated dashboard created" {:path path}) - {:structured-output {:message response-message :path path} :reactions [{:type :metabot.reaction/redirect :url path}] diff --git a/src/metabase/metabot/tools/charts.clj b/src/metabase/metabot/tools/charts.clj index 2c198d322018..891a0d596631 100644 --- a/src/metabase/metabot/tools/charts.clj +++ b/src/metabase/metabot/tools/charts.clj @@ -82,12 +82,10 @@ :charts-state (shared/current-charts-state)}) structured (assoc result :result-type :chart)] - ;; Add the new chart to memory so it can be referenced in the conversation going forward. (when (and (:chart_id new-chart-data) shared/*memory-atom*) (swap! shared/*memory-atom* assoc-in [:state :charts (:chart_id new-chart-data)] new-chart-data)) - {:output (format-chart-output structured) :structured-output structured :data-parts [(streaming/navigate-to-part @@ -95,7 +93,6 @@ {:dataset_query query :display new-viz :displayIsLocked true}))]}) - (catch Exception e (log/error e "Error editing chart") (if (:agent-error? (ex-data e)) diff --git a/src/metabase/metabot/tools/charts/create.clj b/src/metabase/metabot/tools/charts/create.clj index 31f89c97daae..ade8b3ed138d 100644 --- a/src/metabase/metabot/tools/charts/create.clj +++ b/src/metabase/metabot/tools/charts/create.clj @@ -63,18 +63,15 @@ {:agent-error? true :query-id query-id :available-queries (keys queries-state)}))) - ;; Create the chart and generate navigation URL (let [chart-id (str (random-uuid)) results-url (links/query-and-viz-link query chart-type) chart-data {:chart-id chart-id :query-id query-id :chart-type chart-type}] - (log/info "Created chart" {:chart-id chart-id :chart-type chart-type :results-url results-url}) - {:chart-id chart-id :chart-content (format-chart-for-llm chart-data) :chart-link (format-chart-link chart-id) diff --git a/src/metabase/metabot/tools/charts/edit.clj b/src/metabase/metabot/tools/charts/edit.clj index f438eadc836d..e493ef27b63e 100644 --- a/src/metabase/metabot/tools/charts/edit.clj +++ b/src/metabase/metabot/tools/charts/edit.clj @@ -62,11 +62,9 @@ (throw (ex-info "Sorry, I have issues accessing the chart data. Is there anything else I can help you with?" {:agent-error? true :chart-id chart-id}))) - (let [new-chart-data (-> chart-data (assoc :chart_id (str (random-uuid))) (assoc :visualization_settings {:chart_type new-chart-type}))] - {:new-chart-data new-chart-data :result {:chart-id (:chart_id new-chart-data) :chart-content (format-chart-for-llm new-chart-data) diff --git a/src/metabase/metabot/tools/resources.clj b/src/metabase/metabot/tools/resources.clj index 53382b12d0a9..8fcafda2d242 100644 --- a/src/metabase/metabot/tools/resources.clj +++ b/src/metabase/metabot/tools/resources.clj @@ -688,11 +688,9 @@ ;; Fetch all URIs (sequentially for now, could parallelize with pmap) (let [resources (mapv fetch-single-uri uris) formatted (format-resources resources)] - (log/info "Fetched resources" {:total (count resources) :successful (count (filter :content resources)) :errors (count (filter :error resources))}) - {:resources resources :output formatted})) diff --git a/src/metabase/metabot/tools/sql/edit.clj b/src/metabase/metabot/tools/sql/edit.clj index d87b79bb243f..978d71a3c758 100644 --- a/src/metabase/metabot/tools/sql/edit.clj +++ b/src/metabase/metabot/tools/sql/edit.clj @@ -58,13 +58,11 @@ {:agent-error? true :query-id query-id :available-queries (keys queries-state)}))) - (let [current-sql (metabot.u/extract-sql-content query)] (when-not current-sql (throw (ex-info (tru "Query {0} is not a SQL query" query-id) {:agent-error? true :query-id query-id}))) - (let [;; Apply edits sequentially new-sql (reduce apply-sql-edit current-sql edits) dialect (metabot.tools.sql.validation/query->dialect query) diff --git a/src/metabase/metabot/tools/sql/replace.clj b/src/metabase/metabot/tools/sql/replace.clj index 9cba5c549338..77a93ddab7f8 100644 --- a/src/metabase/metabot/tools/sql/replace.clj +++ b/src/metabase/metabot/tools/sql/replace.clj @@ -36,7 +36,6 @@ {:agent-error? true :query-id query-id :available-queries (keys queries-state)}))) - (let [dialect (metabot.tools.sql.validation/query->dialect query) {:keys [valid? transpiled-sql] :as validation-result} diff --git a/src/metabase/metabot/tools/transforms/write.clj b/src/metabase/metabot/tools/transforms/write.clj index d9713c493c2f..454a6185e939 100644 --- a/src/metabase/metabot/tools/transforms/write.clj +++ b/src/metabase/metabot/tools/transforms/write.clj @@ -157,14 +157,11 @@ def transform(): transform_name (assoc :name transform_name) transform_description (assoc :description transform_description) database_id (assoc-in [:source :database] database_id))] - ;; 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 "SQL transform written" {:transform-id transform_id :sql-length (count new-sql)}) - {:structured-output {:transform suggested-transform :thinking thinking :message "Transform SQL updated successfully."} diff --git a/src/metabase/model_persistence/task/persist_refresh.clj b/src/metabase/model_persistence/task/persist_refresh.clj index 04a193d045e8..99ba82aa8cca 100644 --- a/src/metabase/model_persistence/task/persist_refresh.clj +++ b/src/metabase/model_persistence/task/persist_refresh.clj @@ -311,7 +311,6 @@ (comment (let [[start-hour start-minute] (map parse-long (str/split "00:00" #":")) hours 1] - (if (= 24 hours) (format "0 %d %d * * ? *" start-minute start-hour) (format "0 %d %d/%d * * ? *" start-minute start-hour hours)))) diff --git a/src/metabase/models/json_migration.clj b/src/metabase/models/json_migration.clj index 8f733e573c9c..61b63a26a450 100644 --- a/src/metabase/models/json_migration.clj +++ b/src/metabase/models/json_migration.clj @@ -48,6 +48,5 @@ (if (= ~'current-version ~'desired-version) ::identity [~'current-version ~'desired-version])))) - (defmethod ^:private ~name* ::identity [~'column-value ~'_] ~'column-value)))) diff --git a/src/metabase/models/serialization.clj b/src/metabase/models/serialization.clj index e1f31012485c..272adf1b3d93 100644 --- a/src/metabase/models/serialization.clj +++ b/src/metabase/models/serialization.clj @@ -524,7 +524,6 @@ {:model model-name :key k :instance instance}))) - [export-k res]))))) (catch Exception e (throw (ex-info (format "Error extracting %s %s" model-name (:id instance)) diff --git a/src/metabase/models/util/spec_update.clj b/src/metabase/models/util/spec_update.clj index 30c80de5cd28..02af0009fd0b 100644 --- a/src/metabase/models/util/spec_update.clj +++ b/src/metabase/models/util/spec_update.clj @@ -136,11 +136,9 @@ updates-needed? (and parent-updates (not= (sanitize-row (merge existing-row parent-updates)) (sanitize-row row)))] - ;; Update the parent row if needed (when (or force-update? updates-needed?) (t2/update! model (id-col row) (sanitize-row row-with-refs))) - ;; Handle non-ref nested models (when nested-specs (let [nested-without-refs (non-ref-nested-models nested-specs row)] @@ -150,7 +148,6 @@ (with-parent-id row-with-refs nested-without-refs (id-col row)) nested-without-refs update-path)))) - (or force-update? updates-needed?))) (defn- handle-sequential-updates! @@ -183,17 +180,14 @@ (log/tracef "%s no nested spec found, batch creating %d new rows of %s" (format-path path) (count to-create) model) (let [rows (map sanitize-row to-create)] (t2/insert! model rows))))) - (when (seq to-delete) ;; TODO: cascade deletes? (log/debugf "%s deleting %d rows with ids %s" (format-path path) (count to-delete) (str/join ", " (map id-col to-delete))) (t2/delete! model id-col [:in (map id-col to-delete)])) - (when (seq to-update) (log/tracef "%s Attempt updating %s rows of %s" (format-path path) (count to-update) model) (doseq [row to-update] (handle-rows-update! row existing-rows spec path))) - ;; the row might not change, but the nested models might (when (and (seq to-skip) nested-specs) (log/tracef "%s nested models detected, updating unchanged nested models for %s %s" diff --git a/src/metabase/native_query_snippets/models/native_query_snippet/permissions.clj b/src/metabase/native_query_snippets/models/native_query_snippet/permissions.clj index d5dc3ae75393..589ac99ddbe8 100644 --- a/src/metabase/native_query_snippets/models/native_query_snippet/permissions.clj +++ b/src/metabase/native_query_snippets/models/native_query_snippet/permissions.clj @@ -16,7 +16,6 @@ metabase-enterprise.snippet-collections.models.native-query-snippet.permissions ([_] (has-any-native-permissions?)) - ([_ _] (has-any-native-permissions?))) @@ -25,7 +24,6 @@ metabase-enterprise.snippet-collections.models.native-query-snippet.permissions ([_] (has-any-native-permissions?)) - ([_ _] (has-any-native-permissions?))) diff --git a/src/metabase/notification/api/notification.clj b/src/metabase/notification/api/notification.clj index ef8c7f45f512..9cc59408bcdf 100644 --- a/src/metabase/notification/api/notification.clj +++ b/src/metabase/notification/api/notification.clj @@ -107,7 +107,6 @@ (sql.helpers/where [:or [:= :notification_recipient.user_id legacy-user-id] [:= :notification.creator_id legacy-user-id]])))) - (into [] (comp (map t2.realize/realize) (filter mi/can-read?))) diff --git a/src/metabase/notification/models.clj b/src/metabase/notification/models.clj index 55840ec7a036..7f9be3cb8ae2 100644 --- a/src/metabase/notification/models.clj +++ b/src/metabase/notification/models.clj @@ -83,7 +83,6 @@ (into {} (for [nc notification-cards] [[:notification/card (:id nc)] nc]))) {[payload-type nil] nil})))] - (for [notification notifications] (assoc notification k (get payload-type+id->payload [(:payload_type notification) diff --git a/src/metabase/notification/payload/execute.clj b/src/metabase/notification/payload/execute.clj index 5e05f54837f1..3bacf3198bdd 100644 --- a/src/metabase/notification/payload/execute.clj +++ b/src/metabase/notification/payload/execute.clj @@ -317,7 +317,6 @@ {:card-id card-id}))))) fixup-viz-settings format-qp-result))] - (log/debugf "Result has %d rows" (:row_count result)) {:card (t2/select-one :model/Card card-id) :result result diff --git a/src/metabase/notification/payload/impl/card.clj b/src/metabase/notification/payload/impl/card.clj index a44068224734..c1a699851456 100644 --- a/src/metabase/notification/payload/impl/card.clj +++ b/src/metabase/notification/payload/impl/card.clj @@ -36,7 +36,6 @@ goal-val (ui-logic/find-goal-value card_part) comparison-col-rowfn (ui-logic/make-goal-comparison-rowfn (:card card_part) (get-in card_part [:result :data]))] - (when-not (and goal-val comparison-col-rowfn) (throw (ex-info "Unable to compare results to goal for notificationt_card" {:notification_card notification_card diff --git a/src/metabase/notification/payload/temp_storage.clj b/src/metabase/notification/payload/temp_storage.clj index 4f751d4a169a..e0c23a8fc153 100644 --- a/src/metabase/notification/payload/temp_storage.clj +++ b/src/metabase/notification/payload/temp_storage.clj @@ -93,9 +93,7 @@ :max-size (notification.settings/notification-temp-file-size-max-bytes) :max-size-human-readable (human-readable-size (notification.settings/notification-temp-file-size-max-bytes))}))) - (log/infof "📂 Loading streamed results from disk: %s" size-human) - (let [start-time (System/nanoTime) result (with-open [is (-> (io/input-stream file) java.io.BufferedInputStream. @@ -104,7 +102,6 @@ (let [{:keys [preamble]} (nippy/thaw-from-in! is)] (when (seq preamble) (log/debugf "File context: %s" (pr-str preamble)))) - ;; Read row count/marker (let [count-or-marker (nippy/thaw-from-in! is)] (if (= count-or-marker ::streaming) @@ -117,7 +114,6 @@ (if (= row-read ::eof) (persistent! rows) (recur (conj! rows row-read))))) - ;; Counted format - read exact number of rows (loop [rows (transient []) i 0] @@ -241,7 +237,6 @@ (catch Exception e (u/ignore-exceptions (.close output-stream)) (throw e)))) - ;; Return in-memory rows (do (analytics/inc! :metabase-notification/temp-storage @@ -271,7 +266,6 @@ (when context (format "(%s)" (pr-str context)))) (reduced result)) result)) - (do (vswap! rows conj! row) (vswap! cell-count #(+ % (count row))) @@ -285,20 +279,16 @@ ;; Write preamble (nippy/freeze-to-out! output-stream {:preamble context}) (.flush output-stream) - ;; Write sentinel indicating streaming format (nippy/freeze-to-out! output-stream ::streaming) (.flush output-stream) - ;; Write all accumulated rows (doseq [row (persistent! @rows)] (write-row-to-stream! output-stream row)) - ;; Switch to streaming mode (vreset! streaming? true) (vreset! rows (transient [])) ; Clear memory (vreset! streaming-state {:file file :output-stream output-stream}) - (catch Exception e (u/ignore-exceptions (.close output-stream)) (u/ignore-exceptions (io/delete-file file)) diff --git a/src/metabase/notification/seed.clj b/src/metabase/notification/seed.clj index 59c626c8626a..2e7496007928 100644 --- a/src/metabase/notification/seed.clj +++ b/src/metabase/notification/seed.clj @@ -64,7 +64,6 @@ :recipient-type "cc"}} :recipients [{:type :notification-recipient/template :details {:pattern "{{payload.event_info.object.email}}"}}]}]} - ;; alert new confirmation {:internal_id "system-event/alert-new-confirmation" :active true @@ -82,7 +81,6 @@ :recipient-type "cc"}} :recipients [{:type :notification-recipient/template :details {:pattern "{{payload.event_info.object.creator.email}}"}}]}]} - ;; slack token invalid {:internal_id "system-event/slack-token-error" :active true @@ -102,7 +100,6 @@ :details {:pattern "{{context.admin_email}}" :is_optional true}} {:type :notification-recipient/group :permissions_group_id (:id (perms/admin-group))}]}]} - ;; new comment appeared {:internal_id "system-event/comment-created" :active true @@ -120,7 +117,6 @@ :recipient-type "cc"}} :recipients [{:type :notification-recipient/template :details {:pattern "{{payload.event_info.email}}"}}]}]} - ;; support access grant created {:internal_id "system-event/support-access-grant-created" :active true @@ -138,7 +134,6 @@ :recipient-type "cc"}} :recipients [{:type :notification-recipient/template :details {:pattern "{{payload.event_info.support_email}}"}}]}]} - ;; transform job failed {:internal_id "system-event/transform-failed" :active true @@ -198,7 +193,6 @@ [{:keys [internal_id] :as row}] (let [existing-notification (some-> (t2/select-one :model/Notification :internal_id internal_id) models.notification/hydrate-notification)] - (u/prog1 (action existing-notification row) (case <> :create diff --git a/src/metabase/notification/send.clj b/src/metabase/notification/send.clj index 2ed684ed656d..a55bffb10c47 100644 --- a/src/metabase/notification/send.clj +++ b/src/metabase/notification/send.clj @@ -409,7 +409,6 @@ (catch InterruptedException _ (log/warn "Interrupted while waiting for notification executor to terminate") (.shutdownNow ^ThreadPoolExecutor executor))))] - (log/infof "Starting notification thread pool with %d threads" pool-size) (dotimes [_ pool-size] (start-worker!)) diff --git a/src/metabase/notification/task/send.clj b/src/metabase/notification/task/send.clj index 1e25abf93872..848671e0638c 100644 --- a/src/metabase/notification/task/send.clj +++ b/src/metabase/notification/task/send.clj @@ -105,7 +105,6 @@ notification (t2/select-one :model/Notification notification-id)] (log/with-context {:subscription-id subscription-id :notification-id notification-id} - (cond (:active notification) (tracing/with-span :tasks "task.notification.send" {:notification/subscription-id subscription-id diff --git a/src/metabase/parameters/chain_filter.clj b/src/metabase/parameters/chain_filter.clj index d887b4d96464..cac486e1afab 100644 --- a/src/metabase/parameters/chain_filter.clj +++ b/src/metabase/parameters/chain_filter.clj @@ -500,7 +500,6 @@ :has_more_values (if (nil? query-limit) true (= (count values) query-limit))}) - (catch Throwable e (throw (ex-info (tru "Error executing chain filter query") {:field-id field-id diff --git a/src/metabase/permissions/models/collection/graph.clj b/src/metabase/permissions/models/collection/graph.clj index e1bedff68f15..e12c31aca877 100644 --- a/src/metabase/permissions/models/collection/graph.clj +++ b/src/metabase/permissions/models/collection/graph.clj @@ -330,7 +330,6 @@ [diff-old changes] (data/diff (:groups old-graph) (->> (:groups filtered-new-graph) (filter (comp seq second)) (into {})))] - (check-data-analyst-library-permissions changes) (when-not force? (perms.u/check-revision-numbers old-graph filtered-new-graph)) (when (seq changes) diff --git a/src/metabase/permissions/user.clj b/src/metabase/permissions/user.clj index 6f399248c86c..a170bd8cf3cc 100644 --- a/src/metabase/permissions/user.clj +++ b/src/metabase/permissions/user.clj @@ -27,17 +27,14 @@ (map permissions.path/collection-readwrite-path ((requiring-resolve 'metabase.collections.models.collection/user->personal-collection-and-descendant-ids) user-or-id)) - ;; Current User always gets readwrite perms for their Tenant Collection and for its descendants! (3 DB Calls) (map permissions.path/collection-readwrite-path (user->tenant-collection-and-descendant-ids user-or-id)) - ;; Current User always gets read perms for Transforms if they are an analyst (1 DB Call) (when (or (data-perms/is-data-analyst? user-id) (data-perms/is-superuser? user-id)) (concat ["/collection/namespace/transforms/root/"] (map permissions.path/collection-readwrite-path ((requiring-resolve 'metabase.collections.models.collection/collections-in-namespace) :transforms)))) - ;; include the other Perms entries for any Group this User is in (1 DB Call) (map :object (app-db/query {:select [:p.object] :from [[:permissions_group_membership :pgm]] diff --git a/src/metabase/plugins/dependencies.clj b/src/metabase/plugins/dependencies.clj index c778f033fe00..a6dd1785af51 100644 --- a/src/metabase/plugins/dependencies.clj +++ b/src/metabase/plugins/dependencies.clj @@ -89,7 +89,6 @@ [initialized-plugin-names info] (or (all-dependencies-satisfied?* initialized-plugin-names info) - (do (swap! plugins-with-unsatisfied-deps conj info) (log-once (u/format-color 'yellow diff --git a/src/metabase/plugins/jdbc_proxy.clj b/src/metabase/plugins/jdbc_proxy.clj index cd06c7ebffb0..756f9d280cbd 100644 --- a/src/metabase/plugins/jdbc_proxy.clj +++ b/src/metabase/plugins/jdbc_proxy.clj @@ -66,7 +66,6 @@ (let [driver (proxy-driver (.newInstance klass))] (log/debug (u/format-color 'blue "Registering JDBC proxy driver for %s..." class-name)) (DriverManager/registerDriver driver) - ;; deregister the non-proxy version of the driver so it doesn't try to handle our URLs. Most JDBC drivers register ;; themseleves when the classes are loaded (doseq [driver (enumeration-seq (DriverManager/getDrivers)) diff --git a/src/metabase/premium_features/core.clj b/src/metabase/premium_features/core.clj index b882e55ecf1f..4f6ff3d6cd9b 100644 --- a/src/metabase/premium_features/core.clj +++ b/src/metabase/premium_features/core.clj @@ -16,7 +16,6 @@ [metabase.premium-features.defenterprise defenterprise defenterprise-schema] - [metabase.premium-features.token-check ;; TODO: move airgap code to a dedicated namespace? assert-valid-airgap-user-count! @@ -38,7 +37,6 @@ token-check-url transform-metered-as transform-stats] - (metabase.premium-features.settings active-users-count airgap-enabled diff --git a/src/metabase/premium_features/token_check.clj b/src/metabase/premium_features/token_check.clj index f9df2dbf468b..60e2d892db82 100644 --- a/src/metabase/premium_features/token_check.clj +++ b/src/metabase/premium_features/token_check.clj @@ -76,7 +76,6 @@ ;; force this to use a new Connection, it seems to be getting called in situations where the Connection ;; is from a different thread and is invalid by the time we get to use it (let [result (binding [t2.conn/*current-connectable* nil] - ;; Because we need this count *during* token checks, this uses `t2/table-name` to avoid ;; the `after-select` method on users, which calls an EE method that needs ... a token ;; check :| @@ -474,7 +473,6 @@ ;; Expired (> hard-ttl): synchronous refresh :else (do-refresh! token token-hash))) - ;; No local cache, no DB row, or hash mismatch: synchronous fetch (do-refresh! token token-hash))))) (-clear-cache! [_] diff --git a/src/metabase/pulse/api/pulse.clj b/src/metabase/pulse/api/pulse.clj index 00bf1002472b..c938c538ee88 100644 --- a/src/metabase/pulse/api/pulse.clj +++ b/src/metabase/pulse/api/pulse.clj @@ -243,11 +243,9 @@ (perms/check-has-application-permission :monitoring) (catch clojure.lang.ExceptionInfo _e (perms/check-has-application-permission :subscription false))) - (let [pulse-before-update (api/write-check (models.pulse/retrieve-pulse id))] (check-card-read-permissions cards) (collection/check-allowed-to-change-collection pulse-before-update pulse-updates) - ;; if advanced-permissions is enabled, only superuser or non-admin with subscription permission can ;; update pulse's recipients (when (premium-features/enable-advanced-permissions?) @@ -263,7 +261,6 @@ has-subscription-perms? (empty? to-add-recipients)) [403 (tru "Non-admin users without subscription permissions are not allowed to add recipients")]))) - (let [pulse-updates (maybe-add-recipients pulse-updates pulse-before-update)] (t2/with-transaction [_conn] ;; If the collection or position changed with this update, we might need to fixup the old and/or new collection, diff --git a/src/metabase/pulse/models/pulse.clj b/src/metabase/pulse/models/pulse.clj index e5bc1c6ba4ee..2be564c41426 100644 --- a/src/metabase/pulse/models/pulse.clj +++ b/src/metabase/pulse/models/pulse.clj @@ -274,7 +274,6 @@ pulses k #(update-vals (group-by :pulse_id (cards* (map :id pulses))) (fn [cards] (map (fn [card] (dissoc card :pulse_id)) cards))) - :id {:default []})) diff --git a/src/metabase/queries/events/cards_notification_deleted_on_card_save.clj b/src/metabase/queries/events/cards_notification_deleted_on_card_save.clj index a3bf254305fe..8adc1807fcca 100644 --- a/src/metabase/queries/events/cards_notification_deleted_on_card_save.clj +++ b/src/metabase/queries/events/cards_notification_deleted_on_card_save.clj @@ -46,7 +46,6 @@ :notification-recipient/raw-value (-> recipient :details :value) (throw (ex-info "Unknown recipient type" {:recipient recipient}))))))] - (send-message! card recipients recipients-with-no-links actor)) (catch Throwable e (log/error e "Error sending notification email")))) diff --git a/src/metabase/queries/models/card.clj b/src/metabase/queries/models/card.clj index 0f33a76d19f5..5f2ca430b80d 100644 --- a/src/metabase/queries/models/card.clj +++ b/src/metabase/queries/models/card.clj @@ -926,7 +926,6 @@ old-archived archived-update) archived-after? (boolean new-archived)] - ;; you can't specify the dashboard_tab_id if not on a dashboard (api/check-400 (not (and dashboard-tab-id @@ -934,7 +933,6 @@ ;; we'll end up unarchived and a dashboard card => make sure we autoplace (when (and on-dashboard-after? (not archived-after?)) (autoplace-dashcard-for-card! new-dashboard-id dashboard-tab-id card-before-update)) - (when (and ;; we start out as a DQ, and on-dashboard-before? @@ -949,7 +947,6 @@ (and (not on-dashboard-after?) delete-old-dashcards?))) (autoremove-dashcard-for-card! card-id old-dashboard-id)) - ;; we're moving from a collection to a dashboard, and the user has told us to remove the dashcards for other ;; dashboards (when (and on-dashboard-after? @@ -1180,7 +1177,6 @@ ;; don't block our precious core.async thread, run the actual DB updates on a separate thread (t2/with-transaction [_conn] (api/maybe-reconcile-collection-position! card-before-update card-updates) - (autoplace-or-remove-dashcards-for-card! card-before-update card-updates delete-old-dashcards?) (let [updated-fields (u/select-keys-when card-updates ;; `collection_id` and `description` can be `nil` (in order to unset them). @@ -1189,9 +1185,7 @@ :non-nil #{:dataset_query :display :name :visualization_settings :archived :enable_embedding :type :parameters :parameter_mappings :embedding_params :result_metadata :collection_preview :verified-result-metadata?})] - (assert-is-valid-dashboard-internal-update card-updates card-before-update) - (when (and (card-is-verified? card-before-update) (changed? card-before-update (select-keys updated-fields card-compare-keys))) ;; this is an enterprise feature but we don't care if enterprise is enabled here. If there is a review we need diff --git a/src/metabase/queries/models/query.clj b/src/metabase/queries/models/query.clj index a9104b806f4d..9eac4389b5ed 100644 --- a/src/metabase/queries/models/query.clj +++ b/src/metabase/queries/models/query.clj @@ -50,7 +50,6 @@ (let [avg-execution-time (h2x/cast (int-casting-type) (h2x/round (h2x/+ (h2x/* [:inline 0.9] :average_execution_time) [:inline (* 0.1 execution-time-ms)]) [:inline 0]))] - (or ;; if it DOES NOT have a query (yet) set that. In 0.31.0 we added the query.query column, and it gets set for all ;; new entries, so at some point in the future we can take this out, and save a DB call. diff --git a/src/metabase/queries_rest/api/card.clj b/src/metabase/queries_rest/api/card.clj index 992282710c27..3732a6261651 100644 --- a/src/metabase/queries_rest/api/card.clj +++ b/src/metabase/queries_rest/api/card.clj @@ -799,7 +799,6 @@ :collection_id new-collection-id-or-nil) ;; collection_position for the next card in the collection starting-position (inc (get max-position-result :max_position 0))] - ;; This is using `map` but more like a `doseq` with multiple seqs. Wrapping this in a `doall` as we don't want it ;; to be lazy and we're just going to discard the results (doall @@ -836,7 +835,6 @@ ;; ...and check that we have write permissions for the old collections if applicable (doseq [old-collection-id (set (filter identity (map :collection_id cards)))] (api/write-check :model/Collection old-collection-id)) - ;; Ensure all of the card updates occur in a transaction. Read committed (the default) really isn't what we want ;; here. We are querying for the max card position for a given collection, then using that to base our position ;; changes if the cards are moving to a different collection. Without repeatable read here, it's possible we'll @@ -846,7 +844,6 @@ ;; are gone and update the position in the new collection (when-let [cards-with-position (seq (filter :collection_position cards))] (update-collection-positions! new-collection-id-or-nil cards-with-position)) - ;; ok, everything checks out. Set the new `collection_id` for all the Cards that haven't been updated already (when-let [cards-without-position (seq (for [card cards :when (not (:collection_position card))] diff --git a/src/metabase/query_permissions/impl.clj b/src/metabase/query_permissions/impl.clj index 8ab59885976c..d1877f35fadf 100644 --- a/src/metabase/query_permissions/impl.clj +++ b/src/metabase/query_permissions/impl.clj @@ -373,12 +373,10 @@ (when-let [paths (:paths required-perms)] (or (perms/set-has-full-permissions-for-set? @api/*current-user-permissions-set* paths) (throw (perms-exception paths)))) - ;; Check view-data and create-queries permissions, for individual tables or the entire DB: (when (or (not (has-perm-for-query? query :perms/view-data required-perms)) (not (has-perm-for-query? query :perms/create-queries required-perms))) (throw (perms-exception required-perms))) - true (catch clojure.lang.ExceptionInfo e (if throw-exceptions? @@ -391,11 +389,9 @@ (try (let [required-perms (required-perms-for-query query)] (check-data-perms query required-perms) - ;; Check card read permissions for any cards referenced in subqueries! (doseq [card-id (:card-ids required-perms)] (check-card-read-perms database-id card-id)) - true) (catch clojure.lang.ExceptionInfo _e false))) diff --git a/src/metabase/query_processor/core.clj b/src/metabase/query_processor/core.clj index 6e9e4e6bccbc..d5aabb8500fa 100644 --- a/src/metabase/query_processor/core.clj +++ b/src/metabase/query_processor/core.clj @@ -50,7 +50,6 @@ ;; Limit — metabase.query-processor.middleware.limit [qp.limit disable-max-results] - ;; Pivot — metabase.query-processor.pivot [qp.pivot run-pivot-query] diff --git a/src/metabase/query_processor/middleware/add_implicit_joins.clj b/src/metabase/query_processor/middleware/add_implicit_joins.clj index 2af0f495f6c5..1372343a1cf8 100644 --- a/src/metabase/query_processor/middleware/add_implicit_joins.clj +++ b/src/metabase/query_processor/middleware/add_implicit_joins.clj @@ -82,7 +82,6 @@ (let [{source-table-id :table-id} (lib.metadata/field metadata-providerable pk-id) {table-name :name, :as source-table} (lib.metadata/table metadata-providerable source-table-id) alias-for-join (join-alias table-name (or fk-field-name (:name fk-field)) fk-join-alias)] - (-> (lib/join-clause source-table) (lib/with-join-alias alias-for-join) (lib/with-join-conditions [(lib/= [:field diff --git a/src/metabase/query_processor/middleware/cache/impl.clj b/src/metabase/query_processor/middleware/cache/impl.clj index a4d5f70b871e..170ee9402862 100644 --- a/src/metabase/query_processor/middleware/cache/impl.clj +++ b/src/metabase/query_processor/middleware/cache/impl.clj @@ -29,7 +29,6 @@ (do (check-total (swap! byte-count + (alength ^bytes x))) (.write os ^bytes x)))) - ([^bytes ba ^Integer off ^Integer len] (check-total (swap! byte-count + len)) (.write os ba off len)))))) diff --git a/src/metabase/query_processor/middleware/permissions.clj b/src/metabase/query_processor/middleware/permissions.clj index 67703bade8ca..f409aec79656 100644 --- a/src/metabase/query_processor/middleware/permissions.clj +++ b/src/metabase/query_processor/middleware/permissions.clj @@ -136,14 +136,11 @@ ;; Recursively check permissions for any source Cards (doseq [card-id source-card-ids] (query-perms/check-card-read-perms database-id card-id)) - ;; Check that we have the data permissions to run this card (query-perms/check-data-perms outer-query required-perms :throw-exceptions? true) - ;; Check that all columns from source cards result_metadata are accessible (doseq [card-id source-card-ids] (query-perms/check-card-result-metadata-data-perms database-id card-id)) - ;; Recursively check permissions for any Cards referenced by this query via template tags (doseq [{query :dataset-query} (lib/template-tags-referenced-cards (lib/query (qp.store/metadata-provider) outer-query))] diff --git a/src/metabase/query_processor/middleware/resolve_joins.clj b/src/metabase/query_processor/middleware/resolve_joins.clj index 1c73d1b8b4cb..af513a97a1f3 100644 --- a/src/metabase/query_processor/middleware/resolve_joins.clj +++ b/src/metabase/query_processor/middleware/resolve_joins.clj @@ -38,7 +38,6 @@ (not (contains? duplicate-ids field-id))) force-field-name-ref? (and (pos-int? id-or-name) (contains? duplicate-ids field-id))]] - (cond force-id-ref? [:field opts field-id] diff --git a/src/metabase/query_processor/parameters/dates.clj b/src/metabase/query_processor/parameters/dates.clj index a69ff7831008..af6d22ae372d 100644 --- a/src/metabase/query_processor/parameters/dates.clj +++ b/src/metabase/query_processor/parameters/dates.clj @@ -194,7 +194,6 @@ :unit :day})) :filter (fn [_ field-clause] (lib/= (with-temporal-unit-if-field field-clause :day) (lib/relative-datetime :current)))} - {:parser #(= % "yesterday") :range (fn [_ dt] (let [dt-res (t/local-date dt)] @@ -203,7 +202,6 @@ :unit :day})) :filter (fn [_ field-clause] (lib/= (with-temporal-unit-if-field field-clause :day) (lib/relative-datetime -1 :day)))} - ;; Adding a tilde (~) at the end of a pasts filter means we should include the current day/etc. ;; e.g. past30days = past 30 days, not including partial data for today ({:include-current false}) ;; past30days~ = past 30 days, *including* partial data for today ({:include-current true}). @@ -233,7 +231,6 @@ (- int-value) (keyword unit)) (lib/update-options assoc :include-current (include-current? relative-suffix)))))} - {:parser (regex->parser (re-pattern (str #"next([0-9]+)" temporal-units-regex #"s" relative-suffix-regex)) [:int-value :unit :relative-suffix :int-value-1 :unit-1]) :range (fn [{:keys [unit int-value unit-range to-period relative-suffix unit-1 int-value-1]} dt] @@ -252,7 +249,6 @@ (keyword unit-1)) (-> (lib/time-interval field-clause int-value (keyword unit)) (lib/update-options assoc :include-current (include-current? relative-suffix)))))} - {:parser (regex->parser (re-pattern (str #"last" temporal-units-regex)) [:unit]) :range (fn [{:keys [unit unit-range to-period]} dt] @@ -260,7 +256,6 @@ (unit-range last-unit last-unit))) :filter (fn [{:keys [unit]} field-clause] (lib/time-interval field-clause :last (keyword unit)))} - {:parser (regex->parser (re-pattern (str #"this" temporal-units-regex)) [:unit]) :range (fn [{:keys [unit unit-range]} dt] diff --git a/src/metabase/query_processor/store.clj b/src/metabase/query_processor/store.clj index db3d6db1bda1..a1f2b7483e8c 100644 --- a/src/metabase/query_processor/store.clj +++ b/src/metabase/query_processor/store.clj @@ -161,7 +161,6 @@ {:existing-id existing-database-id :new-id new-database-id :type qp.error-type/invalid-query})))) - ;; Metadata Providerable (let [new-provider (-> database-id-or-metadata-providerable lib.metadata/->metadata-provider diff --git a/src/metabase/query_processor/streaming/csv.clj b/src/metabase/query_processor/streaming/csv.clj index 77ce08685416..d259d17778ec 100644 --- a/src/metabase/query_processor/streaming/csv.clj +++ b/src/metabase/query_processor/streaming/csv.clj @@ -109,10 +109,8 @@ ;; removed from the exported data pivot-export-options (vreset! pivot-data {:pivot-grouping-index pivot-grouping-index})) - (vreset! ordered-formatters (mapv #(formatter/create-formatter results_timezone % viz-settings format-rows?) ordered-cols)) - ;; Write the column names for non-pivot tables (when (or (not pivot?) (not enable-pivoted-exports?)) (let [header (m/remove-nth (or pivot-grouping-index (inc (count col-names))) col-names)] diff --git a/src/metabase/revisions/impl/dashboard.clj b/src/metabase/revisions/impl/dashboard.clj index b66cb754039d..43293d25f844 100644 --- a/src/metabase/revisions/impl/dashboard.clj +++ b/src/metabase/revisions/impl/dashboard.clj @@ -165,7 +165,6 @@ (> num-prev-cards num-new-cards)) (deferred-trun "removed a card" "removed {0} cards" num-cards-diff) (set/subset? keys-changes #{:row :col :size_x :size_y}) (deferred-tru "rearranged the cards") :else (deferred-tru "modified the cards")))) - (when (or (:tabs changes) (:tabs removals)) (let [prev-tabs (:tabs prev-dashboard) new-tabs (:tabs dashboard) diff --git a/src/metabase/search/api.clj b/src/metabase/search/api.clj index 33adbe3d4a25..d1f71a7777a9 100644 --- a/src/metabase/search/api.clj +++ b/src/metabase/search/api.clj @@ -92,7 +92,6 @@ (if (and (task/job-exists? task.search-index/reindex-job-key) (or (not ingestion/*force-sync*) config/is-test?)) (do (task/trigger-now! task.search-index/reindex-job-key) {:message "task triggered"}) (do (search/reindex!) {:message "reindex triggered"})) - (throw (ex-info "Search index is not supported for this installation." {:status-code 501})))) (mu/defn- set-weights! diff --git a/src/metabase/search/appdb/core.clj b/src/metabase/search/appdb/core.clj index e38a8be8332d..cf040f835286 100644 --- a/src/metabase/search/appdb/core.clj +++ b/src/metabase/search/appdb/core.clj @@ -149,7 +149,6 @@ :timeout-ms 2000 :interval-ms 100}) (log/warn "Returning search results even though they may be stale. Queue size:" (pending-updates))))) - (let [weights (search.config/weights search-ctx) scorers (search.scoring/scorers search-ctx) query (->> (search.index/search-query search-string search-ctx [:legacy_input]) @@ -198,7 +197,6 @@ (do (log/info "Forcing early reindex because existing index is old") (search.engine/reindex! :search.engine/appdb {})) - (let [created? (search.index/ensure-ready! opts)] (when (or created? re-populate?) (log/info "Populating index") diff --git a/src/metabase/search/appdb/index.clj b/src/metabase/search/appdb/index.clj index 399c8951cced..cd4f7889e274 100644 --- a/src/metabase/search/appdb/index.clj +++ b/src/metabase/search/appdb/index.clj @@ -453,7 +453,6 @@ (when-not *mocking-tables* (when (nil? (active-table)) (sync-tracking-atoms!))) - (when (or force-reset? (not (exists? (active-table)))) (reset-index!)))) diff --git a/src/metabase/search/config.clj b/src/metabase/search/config.clj index 4d48608af234..a934c3e9e63c 100644 --- a/src/metabase/search/config.clj +++ b/src/metabase/search/config.clj @@ -243,7 +243,6 @@ [:is-impersonated-user? {:optional true} [:maybe :boolean]] [:is-sandboxed-user? {:optional true} [:maybe :boolean]] [:current-user-perms [:set perms/PathSchema]] - [:model-ancestors? :boolean] [:models [:set SearchableModel]] ;; TODO this is optional only for tests, clean those up! diff --git a/src/metabase/search/core.clj b/src/metabase/search/core.clj index dd3ab524e3b4..2cee1542e75b 100644 --- a/src/metabase/search/core.clj +++ b/src/metabase/search/core.clj @@ -25,24 +25,19 @@ (p/import-vars [search.config SearchableModel] - [search.engine model-set] - [search.impl search ;; We could avoid exposing this by wrapping `query-model-set` and `search` with it. search-context] - [search.ingestion bulk-ingest! max-searchable-value-length searchable-value-trim-sql] - [search.spec spec define-spec] - [search.util collapse-id indexed-entity-id->model-index-id diff --git a/src/metabase/search/ingestion.clj b/src/metabase/search/ingestion.clj index 78e829bb03a2..364bd9148d6c 100644 --- a/src/metabase/search/ingestion.clj +++ b/src/metabase/search/ingestion.clj @@ -327,7 +327,6 @@ ;; but it's fine for now because that model doesn't have a where clause so never needs to be purged during an update. ;; Long-term, we should find a better approach to knowing what to purge. to-delete (remove indexed-pairs passed-documents)] - (update! documents to-delete)) {})))) diff --git a/src/metabase/search/spec.clj b/src/metabase/search/spec.clj index 3db301ed6126..ce17fc29ed48 100644 --- a/src/metabase/search/spec.clj +++ b/src/metabase/search/spec.clj @@ -310,7 +310,6 @@ (assoc res model #{{:search-model s :fields table-fields :where (replace-qualification join-condition table-alias :updated)}}))) - {(:model spec) #{{:search-model s :fields (:this (find-fields spec)) :where (construct-source-where (-> spec :attrs :id))}}} diff --git a/src/metabase/secrets/models/secret.clj b/src/metabase/secrets/models/secret.clj index 790d11c6e669..01a3bc3eebbc 100644 --- a/src/metabase/secrets/models/secret.clj +++ b/src/metabase/secrets/models/secret.clj @@ -184,7 +184,6 @@ (throw (ex-info (tru "{0} (a local file path) cannot be used in Metabase hosted environment" (:path kws)) {:invalid-db-details-entry (select-keys details [(:path kws)])}))) - (when (and secret-map ;; If the client sent us back protected-password then it should be ignored and value loaded from Secret. (not= (seq (:value secret-map)) diff --git a/src/metabase/server/middleware/security.clj b/src/metabase/server/middleware/security.clj index 33e83c4741e3..864ce8513707 100644 --- a/src/metabase/server/middleware/security.clj +++ b/src/metabase/server/middleware/security.clj @@ -11,7 +11,6 @@ [metabase.mcp.core :as mcp] [metabase.request.core :as request] [metabase.server.settings :as server.settings] - [metabase.settings.core :as setting] [metabase.util :as u] [metabase.util.log :as log] diff --git a/src/metabase/server/routes.clj b/src/metabase/server/routes.clj index fa4614edab9e..2ebd036610f8 100644 --- a/src/metabase/server/routes.clj +++ b/src/metabase/server/routes.clj @@ -76,7 +76,6 @@ ;; hashes, so we serve them with far-future immutable cache headers. (GET ["/embedding-sdk/chunks/:filename" :filename #"[^/]+\.js"] [filename :as request] ((mw.embedding-sdk-bundle/serve-chunk-handler filename) request)) - ;; fall back to serving _all_ other files under /app (route/resources "/" {:root "frontend_client/app"}) (route/not-found {:status 404 :body "Not found."})) @@ -109,11 +108,9 @@ (GET "/readyz" [] health-handler) ;; ^/livez -> Liveness probe (no DB access) (GET "/livez" [] livez-handler) - ;; Handle CORS preflight requests for auth routes (OPTIONS "/auth/*" [] {:status 200 :body ""}) (OPTIONS "/api/*" [] {:status 200 :body ""}) - ;; ^/api/ -> All other API routes (context "/api" [] (api-handler api-routes)) ;; ^/app/ -> static files under frontend_client/app diff --git a/src/metabase/settings/models/setting.clj b/src/metabase/settings/models/setting.clj index 3489b2842f12..509ab927db6b 100644 --- a/src/metabase/settings/models/setting.clj +++ b/src/metabase/settings/models/setting.clj @@ -195,79 +195,59 @@ [:name :keyword] [:munged-name :string] [:namespace :symbol] - ;; description is validated via the macro, not schema [:description :any] - ;; Use `:doc` to include a map with additional documentation, for use when generating the environment variable docs ;; from source. To exclude a setting from documentation, set to `false`. See metabase.cmd.env-var-dox. [:doc :any] [:default :any] - ;; all values are stored in DB as Strings, [:type Type] - ;; different getters/setters take care of parsing/unparsing [:getter ifn?] [:setter ifn?] - ;; an init function can be used to seed initial values [:init [:maybe ifn?]] - ;; type annotation, e.g. ^String, to be applied. Defaults to tag based on :type [:tag :symbol] - ;; is this sensitive (never show in plaintext), like a password? (default: false) [:sensitive? :boolean] - ;; where this setting should be visible (default: :admin) [:visibility Visibility] - ;; should this setting be encrypted. Available options are `:no` or `:when-encryption-key-set` (the setting will be ;; encrypted when `MB_ENCRYPTION_SECRET_KEY` is set, otherwise we can't encrypt). This is required for `:timestamp`, ;; `:json`, and `:csv`-typed settings. Defaults to `:no` for all other types. [:encryption [:enum :no :when-encryption-key-set]] - ;; should this setting be serialized? [:export? :boolean] - ;; should the getter always fetch this value "fresh" from the DB? (default: false) [:cache? :boolean] - ;; if non-nil, contains the Metabase version in which this setting was deprecated [:deprecated [:maybe :string]] - ;; whether this Setting can be Database-local or User-local. See [[metabase.settings.models.setting]] docstring for more info. [:database-local LocalOption] [:user-local LocalOption] - ;; should this setting be read from env vars? [:can-read-from-env? :boolean] - ;; called whenever setting value changes, whether from update-setting! or a cache refresh. used to handle cases ;; where a change to the cache necessitates a change to some value outside the cache, like when a change the ;; `:site-locale` setting requires a call to `java.util.Locale/setDefault` [:on-change [:maybe ifn?]] - ;; If non-nil, determines the Enterprise feature flag required to use this setting. If the feature is not enabled, ;; the setting will behave the same as if `enabled?` returns `false` (see below). [:feature [:maybe :keyword]] - ;; Function which returns true if the setting should be enabled. If it returns false, the setting will throw an ;; exception when it is attempted to be set, and will return its default value when read. Defaults to always enabled. [:enabled? [:maybe ifn?]] - ;; Keyword that determines what kind of audit log entry should be created when this setting is written. Options are ;; `:never`, `:no-value`, `:raw-value`, and `:getter`. User- and database-local settings are never audited. `:getter` ;; should be used for most non-sensitive settings, and will log the value returned by its getter, which may be a ;; the default getter or a custom one. ;; (default: `:no-value`) [:audit [:maybe [:enum :never :no-value :raw-value :getter]]] - ;; If non-nil, determines the database driver feature required for this setting. This is only valid for database-local ;; settings. If the database driver doesn't support the required feature, setting this will throw an exception. [:driver-feature [:maybe :keyword]] - ;; Function that takes a database and returns true if this setting should be enabled for that specific database. ;; If the function returns false, attempting to set this will fail. ;; @@ -277,7 +257,6 @@ ;; ;; This is only valid for database-local settings. [:enabled-for-db? [:maybe ifn?]] - ;; A previous name for this setting whose env var (e.g. MB_OLD_NAME) and database ;; key are checked as a fallback when the primary source is not set. Logs a warning ;; when the env var fallback is in use. @@ -764,7 +743,6 @@ (let [setting-def (resolve-setting setting-definition-or-name) {:keys [getter enabled? feature]} setting-def disable-cache? (or config/*disable-setting-cache* (not (:cache? setting-def)))] - ;; Reading database-local settings is failure prone, so catch easy mistakes. (when (= :only (:database-local setting-def)) (cond @@ -778,7 +756,6 @@ (and (:enabled-for-db? setting-def) (not *database*)) (log/warnf "Skipping enabled-for-db? check for %s as we don't have the underlying toucan2 db instance." (:name setting-def)))) - (if (or (and feature (not (has-feature? feature))) (and enabled? (not (enabled?))) (and *database* (disabled-for-db-reasons? setting-def *database*))) @@ -1094,7 +1071,6 @@ ;; if the setting isn't a type likely to contain secrets, default to plaintext (when (contains? #{:boolean :integer :positive-integer :double :keyword :timestamp} (:type setting)) :no) - (throw (ex-info (trs "`:encryption` is a required option for setting {0}" (:name setting)) {:setting setting})))) diff --git a/src/metabase/sso/common.clj b/src/metabase/sso/common.clj index 1141aa7a9da5..17b1176d9e49 100644 --- a/src/metabase/sso/common.clj +++ b/src/metabase/sso/common.clj @@ -48,7 +48,6 @@ [:not-in :group_id (excluded-group-ids)]]}) [to-remove to-add] (data/diff current-group-ids (set/difference (set (map u/the-id new-groups-or-ids)) (excluded-group-ids)))] - (sync-group-memberships*! user-or-id to-remove to-add))) ([user-or-id new-groups-or-ids mapped-groups-or-ids] (let [mapped-group-ids (set (map u/the-id mapped-groups-or-ids)) diff --git a/src/metabase/sso/providers/oidc.clj b/src/metabase/sso/providers/oidc.clj index 7eaadca5e7ba..7b31e22cedb5 100644 --- a/src/metabase/sso/providers/oidc.clj +++ b/src/metabase/sso/providers/oidc.clj @@ -87,7 +87,6 @@ first-name (get claims (keyword firstname-attr)) last-name (get claims (keyword lastname-attr)) provider-id (:sub claims)] - (when email {:email email :first_name first-name @@ -115,7 +114,6 @@ {:success? false :error :invalid-callback :message (get-in validation [:error :description] "Invalid callback parameters")} - ;; Enrich config with discovery once for the entire callback flow (let [enriched-config (enrich-config-with-discovery config) code (:code validation) @@ -124,7 +122,6 @@ {:success? false :error :token-exchange-failed :message "Failed to exchange authorization code for tokens"} - ;; Validate ID token (let [jwks-uri (oidc.discovery/get-jwks-uri enriched-config) validation-config {:jwks-uri jwks-uri @@ -139,7 +136,6 @@ {:success? false :error :invalid-token :message (:error validation-result)} - ;; Extract user data from claims (let [claims (:claims validation-result) user-data (extract-user-data claims config)] @@ -160,7 +156,6 @@ {:success? false :error :configuration-error :message "Authorization endpoint not found. Check OIDC configuration or discovery."} - ;; Generate authorization URL (let [state (oidc.common/generate-state) nonce (oidc.common/generate-nonce) diff --git a/src/metabase/sso/providers/slack_connect.clj b/src/metabase/sso/providers/slack_connect.clj index 93a4b69f6f6d..86763552a94f 100644 --- a/src/metabase/sso/providers/slack_connect.clj +++ b/src/metabase/sso/providers/slack_connect.clj @@ -125,7 +125,6 @@ {:success? false :error :configuration-error :message (tru "Failed to build Slack OIDC configuration")} - (let [auth-result (next-method _provider (assoc request :oidc-config oidc-config))] (if (and (:success? auth-result) (:user-data auth-result)) diff --git a/src/metabase/sync/sync_metadata/fks.clj b/src/metabase/sync/sync_metadata/fks.clj index 0da2ee2b6683..14f937fb402c 100644 --- a/src/metabase/sync/sync_metadata/fks.clj +++ b/src/metabase/sync/sync_metadata/fks.clj @@ -35,7 +35,6 @@ ;; ensure we are not overriding user-set fks [:= :u.fk_target_field_id nil] [:= :u.semantic_type nil] - [:= :t.db_id db-id] [:= [:lower :f.name] (u/lower-case-en column-name)] [:= [:lower :t.name] (u/lower-case-en table-name)] diff --git a/src/metabase/sync/sync_metadata/tables.clj b/src/metabase/sync/sync_metadata/tables.clj index 7749993cd43b..036f4c4db670 100644 --- a/src/metabase/sync/sync_metadata/tables.clj +++ b/src/metabase/sync/sync_metadata/tables.clj @@ -326,7 +326,6 @@ archived (atom 0)] (doseq [table tables-to-archive :let [new-name (str (:name table) suffix)]] - (if (> (count new-name) 256) (log/warnf "Cannot archive table %s, name too long" (:name table)) (do @@ -384,7 +383,6 @@ (sync-util/with-error-handling (format "Error updating table schemas for %s" (sync-util/name-for-logging database)) (adjust-table-schemas! database schemas-to-update)) - ;; update database metadata from database (when (some? (:version db-metadata)) (sync-util/with-error-handling (format "Error creating/reactivating tables for %s" @@ -400,14 +398,11 @@ (when (seq old-table-metadatas) (sync-util/with-error-handling (format "Error retiring tables for %s" (sync-util/name-for-logging database)) (retire-tables! database old-table-metadatas))) - (sync-util/with-error-handling (format "Error updating table metadata for %s" (sync-util/name-for-logging database)) ;; we need to fetch the tables again because we might have retired tables in the previous steps (update-tables-metadata-if-needed! db-table-metadatas (db->our-metadata database) database)) - (let [archived-tables (sync-util/with-error-handling (format "Error archiving tables for %s" (sync-util/name-for-logging database)) (archive-tables! database))] - {:updated-tables (+ (count new-table-metadatas) (count old-table-metadatas) (or archived-tables 0)) :total-tables (count our-metadata)})))) diff --git a/src/metabase/task/impl.clj b/src/metabase/task/impl.clj index 018f9afd631a..8ad8f5652304 100644 --- a/src/metabase/task/impl.clj +++ b/src/metabase/task/impl.clj @@ -284,10 +284,8 @@ ((get-method trigger->info Trigger) trigger) :schedule (.getCronExpression trigger) - :timezone (.getID (.getTimeZone trigger)) - :misfire-instruction ;; not 100% sure why `case` doesn't work here... (condp = (.getMisfireInstruction trigger) diff --git a/src/metabase/testing_api/api.clj b/src/metabase/testing_api/api.clj index 1dd533b6ccdb..82d00a0d4d70 100644 --- a/src/metabase/testing_api/api.clj +++ b/src/metabase/testing_api/api.clj @@ -87,7 +87,6 @@ ["DROP ALL OBJECTS"] ["RUNSCRIPT FROM ?" snapshot-path]]] (jdbc/execute! {:connection conn} sql-args)) - ;; We've found a delightful bug in H2 where if you: ;; - create a table, then ;; - create a view based on the table, then diff --git a/src/metabase/transforms/crud.clj b/src/metabase/transforms/crud.clj index e251045e16a2..6484935abc73 100644 --- a/src/metabase/transforms/crud.clj +++ b/src/metabase/transforms/crud.clj @@ -144,7 +144,6 @@ new (merge old body) target-fields #(-> % :target (select-keys [:schema :name]))] (api/check-403 (and (mi/can-write? old) (mi/can-write? new))) - ;; we must validate on a full transform object (check-feature-enabled! new) (check-database-feature new) diff --git a/src/metabase/transforms/jobs.clj b/src/metabase/transforms/jobs.clj index b4aae183ea49..8e3afcbed109 100644 --- a/src/metabase/transforms/jobs.clj +++ b/src/metabase/transforms/jobs.clj @@ -125,7 +125,6 @@ failures (volatile! [])] (when start-promise (deliver start-promise :started)) - (doseq [transform plan] (if (every? @successful (get deps (:id transform))) (try @@ -136,7 +135,6 @@ ::message (.getMessage e)}))) (vswap! failures conj {::transform transform ::message (i18n/trs "Failed to run because one or more of the transforms it depends on failed.")}))) - (if (seq @failures) {::status :failed ::failures @failures} diff --git a/src/metabase/transforms/models/transform.clj b/src/metabase/transforms/models/transform.clj index e1aa1b271c39..b0fac1cee4d2 100644 --- a/src/metabase/transforms/models/transform.clj +++ b/src/metabase/transforms/models/transform.clj @@ -294,20 +294,17 @@ to-insert (set/difference new-set current-set) ;; Build position map for new ordering new-positions (zipmap new-tag-ids (range))] - ;; Delete removed associations (when (seq to-delete) (t2/delete! :model/TransformTransformTag :transform_id transform-id :tag_id [:in to-delete])) - ;; Update positions for existing tags that moved (doseq [tag-id (filter current-set new-tag-ids)] (let [new-pos (get new-positions tag-id)] (t2/update! :model/TransformTransformTag {:transform_id transform-id :tag_id tag-id} {:position new-pos}))) - ;; Insert new associations with correct positions (when (seq to-insert) (t2/insert! :model/TransformTransformTag diff --git a/src/metabase/transforms/models/transform_job.clj b/src/metabase/transforms/models/transform_job.clj index 10d81d3e8cd0..cd41a06a2741 100644 --- a/src/metabase/transforms/models/transform_job.clj +++ b/src/metabase/transforms/models/transform_job.clj @@ -141,20 +141,17 @@ to-insert (set/difference new-set current-set) ;; Build position map for new ordering new-positions (zipmap new-tag-ids (range))] - ;; Delete removed associations (when (seq to-delete) (t2/delete! :model/TransformJobTransformTag :job_id job-id :tag_id [:in to-delete])) - ;; Update positions for existing tags that moved (doseq [tag-id (filter current-set new-tag-ids)] (let [new-pos (get new-positions tag-id)] (t2/update! :model/TransformJobTransformTag {:job_id job-id :tag_id tag-id} {:position new-pos}))) - ;; Insert new associations with correct positions (when (seq to-insert) (t2/insert! :model/TransformJobTransformTag diff --git a/src/metabase/transforms_base/query.clj b/src/metabase/transforms_base/query.clj index aa8ab55e6147..59d5142f110f 100644 --- a/src/metabase/transforms_base/query.clj +++ b/src/metabase/transforms_base/query.clj @@ -84,7 +84,6 @@ ;; Check cancellation before starting (when (and cancelled? (cancelled?)) (throw (ex-info "Transform cancelled before start" {:status :cancelled}))) - (let [db (get-in source [:query :database]) {driver :engine :as database} (t2/select-one :model/Database db) _ (transforms-base.u/throw-if-db-routing-enabled! transform database) @@ -104,32 +103,24 @@ :output-table (transforms-base.u/qualified-table-name driver target)} opts (transform-opts transform-details) features (transforms-base.u/required-database-features transform)] - (when-not (every? (fn [feature] (driver.u/supports? (:engine database) feature database)) features) (throw (ex-info "The database does not support the requested transform target type." {:driver driver, :database database, :features features}))) - (log/info "Executing transform" id "with target" (pr-str target)) - ;; Create schema if needed (when-not (driver/schema-exists? driver db (:schema target)) (driver/create-schema-if-needed! driver (:conn-spec transform-details) (:schema target))) - ;; Check cancellation before running query (when (and cancelled? (cancelled?)) (throw (ex-info "Transform cancelled before query execution" {:status :cancelled}))) - ;; Run the actual transform (let [result (driver/run-transform! driver transform-details opts)] - ;; Check cancellation after query (when (and cancelled? (cancelled?)) (throw (ex-info "Transform cancelled after query execution" {:status :cancelled}))) - {:status :succeeded :result result :source-range-params source-range-params})) - (catch Exception e (let [data (ex-data e)] (if (= :cancelled (:status data)) diff --git a/src/metabase/transforms_rest/api/transform.clj b/src/metabase/transforms_rest/api/transform.clj index cb9e071a2448..f5c863abc947 100644 --- a/src/metabase/transforms_rest/api/transform.clj +++ b/src/metabase/transforms_rest/api/transform.clj @@ -169,7 +169,6 @@ (transforms.core/check-database-feature body) (transforms.core/check-feature-enabled! body) (transforms.core/validate-incremental-column-type! body) - (api/check (not (transforms-base.u/target-table-exists? body)) 403 (deferred-tru "A table with that name already exists.")) diff --git a/src/metabase/upload/impl.clj b/src/metabase/upload/impl.clj index 269c837e8163..abc0b32e7db8 100644 --- a/src/metabase/upload/impl.clj +++ b/src/metabase/upload/impl.clj @@ -628,7 +628,6 @@ @api/*current-user*) upload-seconds (/ (u/since-ms timer) 1e3) stats (assoc stats :upload-seconds upload-seconds)] - (events/publish-event! :event/upload-create {:user-id (:id @api/*current-user*) :model-id (:id table) @@ -638,7 +637,6 @@ :table-name table-name :model-id (:id card) :stats stats}}) - (analytics.core/track-event! :snowplow/csvupload (assoc stats :event :csv-upload-successful @@ -648,7 +646,6 @@ (analytics/inc! :metabase-csv-upload/failed) (analytics.core/track-event! :snowplow/csvupload (assoc (fail-stats filename file) :event :csv-upload-failed)) - (throw e))))) ;;; +----------------------------- @@ -833,21 +830,16 @@ (driver/insert-into! driver (:id database) (table-identifier table) column-names parsed-rows) (catch Throwable e (throw (ex-info (ex-message e) {:status-code 422})))) - (when create-auto-pk? (add-columns! driver database table {auto-pk-column-keyword ::upload-types/auto-incrementing-int-pk} :primary-key [auto-pk-column-keyword])) - (scan-and-sync-table! database table) (set-display-names! (:id table) (zipmap column-names display-names)) - (when create-auto-pk? (let [auto-pk-field (table-id->auto-pk-column driver (:id table))] (t2/update! :model/Field (:id auto-pk-field) {:display_name (:name auto-pk-field)}))) - (invalidate-cached-models! table) - (events/publish-event! (if replace-rows? :event/upload-replace :event/upload-append) @@ -858,9 +850,7 @@ :schema-name (:schema table) :table-name (:name table) :stats stats}}) - (analytics.core/track-event! :snowplow/csvupload (assoc stats :event :csv-append-successful)) - {:row-count row-count}))) (catch Throwable e (analytics/inc! :metabase-csv-upload/failed) @@ -923,23 +913,19 @@ driver (driver.u/database->driver database) table-name (table-identifier table)] (check-can-delete table database) - ;; Attempt to delete the underlying data from the customer database. ;; We perform this before marking the table as inactive in the app db so that even if it false, the table is still ;; visible to administrators, and the operation is easy to retry again later. (driver.conn/with-write-connection (driver/drop-table! driver (:id database) table-name)) - ;; We mark the table as inactive synchronously, so that it will no longer shows up in the admin list. (t2/update! :model/Table :id (:id table) {:active false}) - ;; Ideally we would immediately trigger any further clean-up associated with the table being deactivated, but at ;; the time of writing this sync isn't wired up to do anything with explicitly inactive tables, and rather ;; relies on their absence from the tables being described during the database sync itself. ;; TODO update the [[metabase.sync]] module to support direct per-table clean-up ;; Ideally this will also clean up more the metadata which we had created around it, e.g. advanced field values. #_(future (sync/retire-table! (assoc table :active false))) - ;; Archive the related cards if the customer opted in. ;; ;; For now, this only covers instances where the card has this as its "primary table", i.e. @@ -950,7 +936,6 @@ (t2/update-returning-pks! :model/Card {:table_id (:id table) :archived false} {:archived true})) - :done)) (def update-action-schema diff --git a/src/metabase/util.cljc b/src/metabase/util.cljc index d95da88151f0..a7cc6eb4f35b 100644 --- a/src/metabase/util.cljc +++ b/src/metabase/util.cljc @@ -772,7 +772,6 @@ (with-out-str #_{:clj-kondo/ignore [:discouraged-var]} (pp/pprint x {:max-width 120})) - :cljs-dev ;; we try to set this permanently above, but it doesn't seem to work in Cljs, so just bind it every time. The ;; default value wastes too much space, 120 is a little easier to read actually. @@ -780,7 +779,6 @@ (with-out-str #_{:clj-kondo/ignore [:discouraged-var]} (pprint/pprint x))) - :default ;; For CLJS release, we don't pull cljs.pprint to reduce bundle size. (str x))) @@ -1181,7 +1179,6 @@ (long (+ cumulative-byte-count (string-byte-count (string-character-at s i))))))) - :cljs (let [buf (js/Uint8Array. max-length-bytes) result (.encodeInto (js/TextEncoder.) s buf)] ;; JS obj {read: chars_converted, write: bytes_written} diff --git a/src/metabase/util/compress.clj b/src/metabase/util/compress.clj index 4a559a3b0acc..543fbf6fa6be 100644 --- a/src/metabase/util/compress.clj +++ b/src/metabase/util/compress.clj @@ -29,7 +29,6 @@ (TarArchiveOutputStream. 512 "UTF-8"))] (.setLongFileMode tar TarArchiveOutputStream/LONGFILE_POSIX) (.setBigNumberMode tar TarArchiveOutputStream/BIGNUMBER_POSIX) - (doseq [^File f (file-seq src) :let [path-in-tar (subs (.getPath f) (count prefix)) entry (TarArchiveEntry. f path-in-tar)]] diff --git a/src/metabase/util/date_2.clj b/src/metabase/util/date_2.clj index 33ebe2828b7a..485c87a6065c 100644 --- a/src/metabase/util/date_2.clj +++ b/src/metabase/util/date_2.clj @@ -570,7 +570,6 @@ (defmethod print-method klass [t writer] ((get-method print-dup klass) t writer)) - (defmethod print-dup klass [t ^java.io.Writer writer] (.write writer (clojure.core/format "#t \"%s\"" (str t))))) diff --git a/src/metabase/util/devtools.cljc b/src/metabase/util/devtools.cljc index 40741804fab1..fcd3a393669a 100644 --- a/src/metabase/util/devtools.cljc +++ b/src/metabase/util/devtools.cljc @@ -14,16 +14,13 @@ (devtools/set-pref! :disable-advanced-mode-check true) (devtools/install!) (js/console.log "CLJS Devtools loaded") - (defn- frontend-dev-port "Gets the frontend dev port from environment variable, defaulting to 8080" [] (or (when-let [port-str (.. js/process -env -MB_FRONTEND_DEV_PORT)] (js/parseInt port-str)) 8080)) - (defonce unload-handler-set? (atom false)) - (defn- ^:dev/after-load on-reload [] (when (compare-and-set! unload-handler-set? false true) (js/console.log "CLJS code hot loaded; setting up webpack invalidation on unload") diff --git a/src/metabase/util/encryption.clj b/src/metabase/util/encryption.clj index 3cfafbca3f0a..6191e0c53afd 100644 --- a/src/metabase/util/encryption.clj +++ b/src/metabase/util/encryption.clj @@ -237,7 +237,6 @@ (log/warnf e "Cannot decrypt encrypted %s. Have you changed or forgot to set MB_ENCRYPTION_SECRET_KEY?" kind))] - (cond (nil? secret-key) v diff --git a/src/metabase/util/formatting/constants.cljc b/src/metabase/util/formatting/constants.cljc index 1fddf719729f..92dabeb8e08b 100644 --- a/src/metabase/util/formatting/constants.cljc +++ b/src/metabase/util/formatting/constants.cljc @@ -54,19 +54,15 @@ (do (defn- basic-map [m] (-> m (update-vals (constantly 1)) clj->js)) - (def ^:export known-date-styles-js "Vanilla JS object version of [[known-date-styles]] that can be used with keyof in TS." (basic-map known-date-styles)) - (def ^:export known-datetime-styles-js "Vanilla JS object version of [[known-datetime-formats]] that can be used with keyof in TS." (basic-map known-datetime-styles)) - (def ^:export known-time-styles-js "Vanilla JS object version of [[known-time-formats]] that can be used with keyof in TS." (basic-map known-time-styles)) - (def ^:export format-strings-js "Vanilla JS object version of [[builder/format-strings]] that can be used with keyof in TS." (basic-map builder/format-strings)))) diff --git a/src/metabase/util/log/capture.cljc b/src/metabase/util/log/capture.cljc index 17c0868ccc25..fa48a5c31474 100644 --- a/src/metabase/util/log/capture.cljc +++ b/src/metabase/util/log/capture.cljc @@ -86,10 +86,8 @@ (do (s/def ::namespace (some-fn symbol? string?)) - (s/def ::level #{:explode :fatal :error :warn :info :debug :trace :whisper}) - (s/def ::with-log-messages-for-level-args (s/cat :bindings (s/spec (s/+ (s/cat :messages-fn-binding symbol? :ns-level (s/or :ns-level (s/spec (s/cat :ns-str ::namespace @@ -97,7 +95,6 @@ :ns ::namespace :level ::level)))) :body (s/+ any?))) - (defmacro with-log-messages-for-level "Capture log messages at a given level in a given namespace and all 'child' namespaces inside `body`. @@ -143,7 +140,6 @@ `(do-with-log-messages-for-level ~(str ns-str) ~(level->int level) (fn [~messages-fn-binding] ~form)))) `(do ~@body) bindings))) - (s/fdef with-log-messages-for-level :args ::with-log-messages-for-level-args :ret any?))) diff --git a/src/metabase/util/malli.cljc b/src/metabase/util/malli.cljc index dd212426e2e2..bad08579efba 100644 --- a/src/metabase/util/malli.cljc +++ b/src/metabase/util/malli.cljc @@ -78,7 +78,7 @@ {:style/indent 0} [& body] (macros/case - :clj + :clj `(binding [mu.fn/*enforce* false] ~@body) @@ -114,8 +114,8 @@ "Like [[schema.core/defmethod]], but for Malli." [multifn dispatch-value & fn-tail] (macros/case - :clj `(-defmethod-clj ~multifn ~dispatch-value ~@fn-tail) - :cljs `(-defmethod-cljs ~multifn ~dispatch-value ~@fn-tail)))) + :clj `(-defmethod-clj ~multifn ~dispatch-value ~@fn-tail) + :cljs `(-defmethod-cljs ~multifn ~dispatch-value ~@fn-tail)))) #?(:clj (defn validate-throw diff --git a/src/metabase/util/malli/registry.cljc b/src/metabase/util/malli/registry.cljc index d8269e5a34cd..6c41c31a04f6 100644 --- a/src/metabase/util/malli/registry.cljc +++ b/src/metabase/util/malli/registry.cljc @@ -222,9 +222,9 @@ ([type docstring schema] `(metabase.util.malli.registry/def ~type ~(macros/case - :clj `(-with-doc ~schema ~docstring) - ;; Ignore docstring for CLJS. - :cljs schema))))) + :clj `(-with-doc ~schema ~docstring) + ;; Ignore docstring for CLJS. + :cljs schema))))) (defn- deref-all-preserving-properties "Like [[mc/deref-all]] but preserves properties attached to a `:ref` by wrapping the result in `:schema`." diff --git a/src/metabase/util/performance.cljc b/src/metabase/util/performance.cljc index b461273cffaf..754c7f40e73c 100644 --- a/src/metabase/util/performance.cljc +++ b/src/metabase/util/performance.cljc @@ -68,7 +68,6 @@ (when-not (.isEmpty ^List coll) (.get ^List coll 0)) (clojure.core/first coll))) - :cljs (def first "Returns the first item in the collection. Calls seq on its argument. If coll is nil, returns nil." @@ -79,7 +78,6 @@ "Like `clojure.core/second`, but uses `RT/nth` directly for allocation-free access." [coll] (RT/nth coll 1 nil)) - :cljs (def second "Same as (first (next x))" @@ -140,7 +138,6 @@ @res (recur res))) res)))))) - :cljs (defn reduce "Passthrough fallback to `clojure.core/reduce`." @@ -160,7 +157,6 @@ (persistent [_] (LazilyPersistentVector/createOwning arr))) - :cljs (deftype SmallTransientImpl [^:mutable arr, ^:mutable cnt, f])) diff --git a/src/metabase/util/queue.clj b/src/metabase/util/queue.clj index a1f26c8cf832..c3e0991f4971 100644 --- a/src/metabase/util/queue.clj +++ b/src/metabase/util/queue.clj @@ -206,7 +206,6 @@ max-next-ms 100}} :- ::listener-options] (if (listener-exists? listener-name) (log/errorf "Listener %s already exists" listener-name) - (let [executor (cp/threadpool pool-size {:name (str "queue-" listener-name)})] (log/infof "Starting listener %s with %d threads %s" (u/format-color 'green listener-name) pool-size (u/emoji "\uD83C\uDFA7")) (dotimes [_ pool-size] @@ -215,7 +214,6 @@ :err-handler err-handler :max-batch-messages max-batch-messages :max-next-ms max-next-ms}))) - (swap! listeners assoc listener-name executor)))) (mu/defn stop-listening! @@ -228,7 +226,6 @@ (cp/shutdown! executor) ;; wait up to 10 seconds for executor to stop. Largely for CI/tests FAIL in (listener-handler-test) (queue_test.clj:178) (.awaitTermination executor 10 TimeUnit/SECONDS) - (swap! listeners dissoc listener-name) (log/infof "Stopping listener %s...done" listener-name)) (log/infof "No running listener named %s" listener-name))) diff --git a/src/metabase/util/time/impl.clj b/src/metabase/util/time/impl.clj index 9b60c9e9deaf..66b97cad507c 100644 --- a/src/metabase/util/time/impl.clj +++ b/src/metabase/util/time/impl.clj @@ -449,7 +449,6 @@ year-matches? ["MMM d, h:mm a " " MMM d, yyyy, h:mm a"])] - (if lhs-fmt (str (t/format lhs-fmt lhs) "–" (t/format rhs-fmt rhs)) (default-format))) diff --git a/src/metabase/util/time/impl.cljs b/src/metabase/util/time/impl.cljs index 1c3fecaac23b..2d8e5cef1e4e 100644 --- a/src/metabase/util/time/impl.cljs +++ b/src/metabase/util/time/impl.cljs @@ -48,11 +48,11 @@ ["dayjs/plugin/objectSupport" :as objectSupport] ["dayjs/plugin/quarterOfYear" :as quarterOfYear] ["dayjs/plugin/utc" :as utc] - ["dayjs/plugin/weekOfYear" :as weekOfYear] ;; cljfmt reorders these namespaces to be out of order, then kondo complains about it. ;; so let's just live with the 1 unordered namespace require for now. :skull: ^{:clj-kondo/ignore [:unsorted-required-namespaces]} ["dayjs/plugin/weekday" :as weekday] + ["dayjs/plugin/weekOfYear" :as weekOfYear] [metabase.util.time.impl-common :as common])) ;; Initialize dayjs plugins @@ -538,7 +538,6 @@ ;; Same year: abbreviate year on first value year-matches? ["MMM D, h:mm A " " MMM D, YYYY, h:mm A"])] - (if lhs-fmt (str (.format lhs lhs-fmt) "–" (.format rhs rhs-fmt)) (default-format))) diff --git a/src/metabase/warehouse_schema/models/table.clj b/src/metabase/warehouse_schema/models/table.clj index cc080d97d05e..e3e9855a0bd6 100644 --- a/src/metabase/warehouse_schema/models/table.clj +++ b/src/metabase/warehouse_schema/models/table.clj @@ -188,13 +188,11 @@ original-table (t2/original table) current-active (:active original-table) new-active (:active changes)] - ;; Prevent setting data_authority back to unconfigured once configured (when (and (not= (keyword (:data_authority original-table :unconfigured)) :unconfigured) (= (keyword (:data_authority changes)) :unconfigured)) (throw (ex-info "Cannot set data_authority back to unconfigured once it has been configured" {:status-code 400}))) - ;; Prevent changing data_source to/from metabase-transform. ;; The "to metabase-transform" direction is allowed during deserialization so an existing synced table ;; can be migrated to a transform-managed table via serdes. @@ -210,7 +208,6 @@ (= new-data-source :metabase-transform)) (throw (ex-info "Cannot set data_source to metabase-transform" {:status-code 400}))))) - ;; Sync visibility_type and data_layer fields (let [changes (sync-visibility-fields changes original-table)] (cond diff --git a/src/metabase/warehouse_schema_rest/api/field.clj b/src/metabase/warehouse_schema_rest/api/field.clj index 3f025c4619ad..ffd4d62a6adf 100644 --- a/src/metabase/warehouse_schema_rest/api/field.clj +++ b/src/metabase/warehouse_schema_rest/api/field.clj @@ -177,7 +177,6 @@ :effective-type effective})))))) removed-fk? (removed-fk-semantic-type? (:semantic_type field) new-semantic-type) fk-target-field-id (get body :fk_target_field_id (:fk_target_field_id field))] - ;; validate that fk_target_field_id is a valid Field in the same database (when fk-target-field-id (check-field-in-same-database! id fk-target-field-id :fk_target_field_id)) diff --git a/src/metabase/warehouses_rest/api.clj b/src/metabase/warehouses_rest/api.clj index 7746651644cc..3a64c116bbdc 100644 --- a/src/metabase/warehouses_rest/api.clj +++ b/src/metabase/warehouses_rest/api.clj @@ -1084,7 +1084,6 @@ ;; with the advanced-config feature enabled. (when (premium-features/enable-cache-granular-controls?) (t2/update! :model/Database id {:cache_ttl cache_ttl})) - (let [db (t2/select-one :model/Database :id id)] ;; the details in db and existing-database have been normalized so they are the same here ;; we need to pass through details-changed? which is calculated before detail normalization diff --git a/src/metabase/xrays/automagic_dashboards/comparison.clj b/src/metabase/xrays/automagic_dashboards/comparison.clj index cdcc0d82290c..8253fdddde68 100644 --- a/src/metabase/xrays/automagic_dashboards/comparison.clj +++ b/src/metabase/xrays/automagic_dashboards/comparison.clj @@ -139,7 +139,6 @@ :card_id (:id card-right) :series series-right :visualization_settings {}})))))) - (populate/add-text-card dashboard {:text (:text card) :width (/ populate/grid-width 2) :height (:height card) diff --git a/test/metabase/actions/actions_test.clj b/test/metabase/actions/actions_test.clj index 421b0028b431..97b916b7e693 100644 --- a/test/metabase/actions/actions_test.clj +++ b/test/metabase/actions/actions_test.clj @@ -239,7 +239,6 @@ (mt/test-drivers (mt/normal-drivers-with-feature :actions) (mt/with-actions-test-data-tables #{"venues" "categories"} (with-actions-test-data-and-actions-permissively-enabled! - ;; attempting to delete the `Pizza` category should fail because there are several rows in `venues` that have ;; this `category_id` -- it's an FK constraint violation. (is (thrown-with-msg? Exception (case driver/*driver* @@ -392,7 +391,6 @@ (let [db-id (mt/id) table-id (mt/id :categories)] (unset-entity-key! table-id) - (is (= 75 (categories-row-count))) (is (= {:type :data-editing/no-pk :status-code 400 @@ -498,7 +496,6 @@ {:database (mt/id) :table-id table-id :row row})))))) - (testing "rows should be updated in the DB" (is (= [[1 "Seed Bowl"] [2 "Millet Treat"] @@ -516,7 +513,6 @@ [2 "American"] [3 "Artisan"]] (first-three-categories))) - (is (= {:type :data-editing/no-pk :status-code 400 :table-id table-id @@ -535,7 +531,6 @@ ::did-not-throw (catch Exception e (or (ex-data e) ::did-not-throw-ex-info))))) - (testing "rows should NOT be updated in the DB" (is (= [[1 "African"] [2 "American"] @@ -774,7 +769,6 @@ :mysql (format "GRANT SELECT ON %s.categories TO '%s'" test-db-name test-user-name))])] (when stmt (jdbc/execute! admin-spec [stmt]))) - ;; Create connection details for test user (no password) (let [test-user-details (cond-> details (= driver/*driver* :mysql) @@ -805,7 +799,6 @@ (is (= 400 (:status-code result))) (is (= actions.error/violate-permission-constraint (:type first-error))) (is (= "You don't have permission to add data to this table." (:message first-error))))) - (testing (str "UPDATE permission denied for " driver/*driver*) (let [result (try (actions/perform-action-v2! @@ -821,7 +814,6 @@ (is (= 400 (:status-code result))) (is (= actions.error/violate-permission-constraint (:type first-error))) (is (= "You don't have permission to update data in this table." (:message first-error))))) - (testing (str "DELETE permission denied for " driver/*driver*) (let [result (try (actions/perform-action-v2! diff --git a/test/metabase/actions/scope_test.clj b/test/metabase/actions/scope_test.clj index cd9a536bdb85..43e88a6501f1 100644 --- a/test/metabase/actions/scope_test.clj +++ b/test/metabase/actions/scope_test.clj @@ -52,20 +52,17 @@ :collection-id collection-id :type :dashcard} (actions.scope/hydrate-scope {:dashcard-id dashcard-id-1})))) - (testing "hydrate for dashcard with native card" (is (= {:dashcard-id dashcard-id-2 :dashboard-id dashboard-id :collection-id collection-id :type :dashcard} (actions.scope/hydrate-scope {:dashcard-id dashcard-id-2})))) - (testing "hydrate for dashboard" (is (= {:dashboard-id dashboard-id :collection-id collection-id :type :dashboard} (actions.scope/hydrate-scope {:dashboard-id dashboard-id})))) - (testing "hydrate for MBQL card" (is (= {:type :model :model-id mbql-model-id @@ -73,14 +70,12 @@ :table-id table-id :database-id db-id} (actions.scope/hydrate-scope {:model-id mbql-model-id})))) - (testing "hydrate for native card" (is (= {:type :model :model-id native-model-id :collection-id collection-id :database-id db-id} (actions.scope/hydrate-scope {:model-id native-model-id})))) - (testing "hydrate for MBQL model" (is (= {:type :model :model-id mbql-model-id @@ -88,14 +83,12 @@ :table-id table-id :database-id db-id} (actions.scope/hydrate-scope {:model-id mbql-model-id})))) - (testing "hydrate for native model" (is (= {:model-id native-model-id :collection-id collection-id :database-id db-id :type :model} (actions.scope/hydrate-scope {:model-id native-model-id})))) - (testing "hydrate for table" (is (= {:table-id table-id :database-id db-id @@ -109,19 +102,16 @@ (actions.scope/normalize-scope {:dashboard-id 1 :dashcard-id 2 :collection-id 5}))) - (is (= {:type :dashboard :dashboard-id 1} (actions.scope/normalize-scope {:dashboard-id 1 :collection-id 5}))) - (is (= {:type :model :model-id 7} (actions.scope/normalize-scope {:model-id 7 :table-id 4 :collection-id 5 :database-id 6}))) - (is (= {:type :table :table-id 4} (actions.scope/normalize-scope {:table-id 4 diff --git a/test/metabase/actions/settings_test.clj b/test/metabase/actions/settings_test.clj index 845867d8d3fe..1b7dc7daaf20 100644 --- a/test/metabase/actions/settings_test.clj +++ b/test/metabase/actions/settings_test.clj @@ -29,7 +29,6 @@ (mt/with-premium-features #{:table-data-editing} (let [enabled-for-db? (:enabled-for-db? (#'setting/resolve-setting :database-enable-table-editing)) disabled-reasons (partial extract-disabled-reasons* enabled-for-db?)] - (testing "returns database routing reason for destination databases" (mt/with-temp [:model/Database router-db {:initial_sync_status "complete"} :model/Database target-db {:initial_sync_status "complete", :router_database_id (:id router-db)} @@ -41,30 +40,25 @@ (disabled-reasons target-db))) (is (false? (can-configure-data-editing-for-db? router-db))) (is (false? (can-configure-data-editing-for-db? target-db))))) - (testing "returns sync in progress reason when sync is incomplete" (mt/with-temp [:model/Database db {:initial_sync_status "incomplete"}] (is (= [:database-metadata/sync-in-progress] (disabled-reasons db))) (is (can-configure-data-editing-for-db? db)))) - (testing "succeeds when database has writable tables" (mt/with-temp [:model/Database db {:initial_sync_status "complete"} :model/Table _table {:db_id (:id db) :is_writable true}] (is (= nil (disabled-reasons db))) (is (can-configure-data-editing-for-db? db)))) - (testing "returns missing permissions reason when tables have unknown write-ability" (mt/with-temp [:model/Database db {:initial_sync_status "complete"} :model/Table _table {:db_id (:id db) :is_writable nil}] (is (= [:database-metadata/not-populated] (disabled-reasons db))) (is (can-configure-data-editing-for-db? db)))) - (testing "returns no writable tables reason when all tables are not writable" (mt/with-temp [:model/Database db {:initial_sync_status "complete"} :model/Table _table {:db_id (:id db) :is_writable false}] (is (= [:permissions/no-writable-table] (disabled-reasons db))) (is (false? (can-configure-data-editing-for-db? db))))) - (testing "returns no writable tables reason when database has no tables" (mt/with-temp [:model/Database db {:initial_sync_status "complete"}] (is (= [:permissions/no-writable-table] (disabled-reasons db))) diff --git a/test/metabase/actions_rest/api_test.clj b/test/metabase/actions_rest/api_test.clj index bf90830d84f7..ab18585c566a 100644 --- a/test/metabase/actions_rest/api_test.clj +++ b/test/metabase/actions_rest/api_test.clj @@ -435,7 +435,6 @@ (is (= uuid (:uuid (mt/user-http-request :crowberto :post 200 (format "action/%d/public_link" action-id))))))))) - (testing "We cannot share an archived action" (mt/with-actions [{:keys [action-id]} (assoc unshared-action-opts :archived true)] (is (= "Not found." @@ -446,17 +445,14 @@ (mt/with-temporary-setting-values [enable-public-sharing false] (is (= "Public sharing is not enabled." (mt/user-http-request :crowberto :post 400 (format "action/%d/public_link" action-id)))))) - (testing "We *cannot* share an action if actions are disabled" (mt/with-actions-disabled (is (= "Actions are not enabled." (:cause (mt/user-http-request :crowberto :post 400 (format "action/%d/public_link" action-id))))))) - (testing "We get a 404 if the Action doesn't exist" (is (= "Not found." (mt/user-http-request :crowberto :post 404 (format "action/%d/public_link" Integer/MAX_VALUE))))))) - (testing "We *cannot* share an action if we aren't admins" (mt/with-actions [{:keys [action-id]} unshared-action-opts] (is (= "You don't have permissions to do that." @@ -477,24 +473,20 @@ (mt/user-http-request :crowberto :delete 204 (format "action/%d/public_link" action-id)) (is (= false (t2/exists? :model/Action :id action-id, :public_uuid (:public_uuid action-opts))))))) - (testing "Test that we cannot unshare an action if it's archived" (let [action-opts (merge {:archived true} (shared-action-opts))] (mt/with-actions [{:keys [action-id]} action-opts] (is (= "Not found." (mt/user-http-request :crowberto :delete 404 (format "action/%d/public_link" action-id))))))) - (testing "Test that we *cannot* unshare a action if we are not admins" (let [action-opts (shared-action-opts)] (mt/with-actions [{:keys [action-id]} action-opts] (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :delete 403 (format "action/%d/public_link" action-id))))))) - (testing "Test that we get a 404 if Action isn't shared" (mt/with-actions [{:keys [action-id]} unshared-action-opts] (is (= "Not found." (mt/user-http-request :crowberto :delete 404 (format "action/%d/public_link" action-id)))))) - (testing "Test that we get a 404 if Action doesn't exist" (is (= "Not found." (mt/user-http-request :crowberto :delete 404 (format "action/%d/public_link" Integer/MAX_VALUE))))))))) @@ -636,27 +628,21 @@ (testing "403 if user does not have permission to view the action" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (format "action/%d/execute" update-action-id) :parameters (json/encode {:id 1}))))) - (testing "404 if id does not exist" (is (= "Not found." (mt/user-http-request :rasta :get 404 (format "action/%d/execute" Integer/MAX_VALUE) :parameters (json/encode {:id 1}))))) - (testing "returns empty map for query actions (not implicit)" (is (= {} (mt/user-http-request :crowberto :get 200 (format "action/%d/execute" query-action-id) :parameters (json/encode {:id 1}))))) - (testing "Can't fetch for create action" (is (= "Values can only be fetched for actions that require a Primary Key." (mt/user-http-request :crowberto :get 400 (format "action/%d/execute" create-action-id) :parameters (json/encode {:id 1}))))) - (testing "fetch for update action return name and id" (is (= {:id 1 :name "Red Medicine"} (mt/user-http-request :crowberto :get 200 (format "action/%d/execute" update-action-id) :parameters (json/encode {:id 1}))))) - (testing "fetch for delete action returns the id only" (is (= {:id 1} (mt/user-http-request :crowberto :get 200 (format "action/%d/execute" delete-action-id) :parameters (json/encode {:id 1}))))) - (mt/with-actions-disabled (testing "error if actions is disabled" (is (= "Actions are not enabled." @@ -672,7 +658,6 @@ {update-action :action-id} {:type :implicit :kind "row/update"}] (testing "an error in SQL will be caught and parsed to a readable erorr message" - (is (= {:message "Unable to update the record." :errors {:user_id "This value does not exist in table \"users\"."}} (mt/user-http-request :rasta :post 400 (format "action/%d/execute" update-action) diff --git a/test/metabase/activity_feed/api_test.clj b/test/metabase/activity_feed/api_test.clj index 3fb2fdc0c7b6..66981499955d 100644 --- a/test/metabase/activity_feed/api_test.clj +++ b/test/metabase/activity_feed/api_test.clj @@ -76,7 +76,6 @@ (is (= (assoc dash-1 :collection (assoc crowberto-personal-coll :is_personal true) :view_count 1) (mt/user-http-request :crowberto :get 200 "activity/most_recently_viewed_dashboard"))))) - (testing "view a dashboard in a public collection" (events/publish-event! :event/dashboard-read {:object-id (:id dash-2) :user-id (mt/user->id :crowberto)}) (is (= (assoc dash-2 :collection (assoc coll :is_personal false) :view_count 1) diff --git a/test/metabase/activity_feed/models/recent_views_test.clj b/test/metabase/activity_feed/models/recent_views_test.clj index ca167159b4d2..07054e0a3926 100644 --- a/test/metabase/activity_feed/models/recent_views_test.clj +++ b/test/metabase/activity_feed/models/recent_views_test.clj @@ -37,7 +37,6 @@ [:model/Collection {coll-id :id} {:name "my coll"} :model/Database {db-id :id} {} :model/Card {card-id :id} {:type "question" :name "name" :display "display" :collection_id coll-id :database_id db-id}] - (recent-views/update-users-recent-views! (mt/user->id :rasta) :model/Card card-id :view) (is (= [{:description nil, :dashboard nil @@ -340,7 +339,6 @@ (recent-views/update-users-recent-views! (mt/user->id :rasta) :model/Dashboard dashboard-id :view) ;; can't read? can't see: (is (= 0 (count (recent-views (mt/user->id :rasta))))) - (is (= 3 (count (mt/with-test-user :rasta (recent-views (mt/user->id :rasta))))))))) @@ -351,19 +349,15 @@ :model/Dashboard {dash-id-2 :id} {} :model/Dashboard {dash-id-3 :id} {}] (is (nil? (recent-views/most-recently-viewed-dashboard-id (mt/user->id :rasta)))) - (recent-views/update-users-recent-views! (mt/user->id :rasta) :model/Dashboard dash-id :view) (recent-views/update-users-recent-views! (mt/user->id :rasta) :model/Dashboard dash-id :view) (is (= dash-id (recent-views/most-recently-viewed-dashboard-id (mt/user->id :rasta)))) - (recent-views/update-users-recent-views! (mt/user->id :rasta) :model/Dashboard dash-id :view) (recent-views/update-users-recent-views! (mt/user->id :rasta) :model/Dashboard dash-id-2 :view) (is (= dash-id-2 (recent-views/most-recently-viewed-dashboard-id (mt/user->id :rasta)))) - (recent-views/update-users-recent-views! (mt/user->id :rasta) :model/Dashboard dash-id :view) (recent-views/update-users-recent-views! (mt/user->id :rasta) :model/Dashboard dash-id-3 :view) (is (= dash-id-3 (recent-views/most-recently-viewed-dashboard-id (mt/user->id :rasta)))) - (testing "archived dashboards are not returned (#45223)" (t2/update! :model/Dashboard dash-id-3 {:archived true}) (is (= dash-id (recent-views/most-recently-viewed-dashboard-id (mt/user->id :rasta)))))))) @@ -535,7 +529,6 @@ [:model/Dashboard [dashboard-id dashboard-id-2 dashboard-id-3]] [:model/Collection [collection-id collection-id-2 collection-id-3]] [:model/Table [table-id table-id-2 table-id-3]]]] - (doseq [model-id model-ids] (recent-views/update-users-recent-views! (mt/user->id :rasta) model model-id :view))) (with-redefs [mi/can-read? (constantly true) diff --git a/test/metabase/agent_api/api_test.clj b/test/metabase/agent_api/api_test.clj index 397e477485ef..03a4be57f4b9 100644 --- a/test/metabase/agent_api/api_test.clj +++ b/test/metabase/agent_api/api_test.clj @@ -45,7 +45,6 @@ response (client/client :get 200 "agent/v1/ping" {:request-options {:headers {"x-metabase-session" session-key}}})] (is (= {:message "pong"} response)))) - (testing "Invalid session token returns 401" (let [fake-session-key (str (random-uuid)) response (client/client :get 401 "agent/v1/ping" @@ -97,11 +96,9 @@ :fields sequential? :related_tables sequential?} (mt/user-http-request :rasta :get 200 (str "agent/v1/table/" table-id)))))) - (testing "Returns 404 for non-existent table" (is (= "Not found." (mt/user-http-request :rasta :get 404 "agent/v1/table/999999")))) - (testing "Respects query parameters" (let [table-id (mt/id :orders)] (is (=? {:type "table" @@ -109,12 +106,10 @@ :fields empty?} (mt/user-http-request :rasta :get 200 (str "agent/v1/table/" table-id "?with-fields=false&with-related-tables=false")))))) - (testing "Field values are excluded by default" (let [table-id (mt/id :orders) table (mt/user-http-request :rasta :get 200 (str "agent/v1/table/" table-id))] (is (every? #(nil? (:field_values %)) (:fields table))))) - (testing "Field values are included when explicitly requested" (let [table-id (mt/id :orders) table (mt/user-http-request :rasta :get 200 (str "agent/v1/table/" table-id "?with-field-values=true"))] @@ -161,14 +156,12 @@ :name "Test Metric" :queryable_dimensions sequential?} (mt/user-http-request :rasta :get 200 (str "agent/v1/metric/" (:id metric)))))) - (testing "Respects query parameters" (is (=? {:type "metric" :id (:id metric)} (mt/user-http-request :rasta :get 200 (str "agent/v1/metric/" (:id metric) "?with-queryable-dimensions=false&with-field-values=false"))))) - (testing "Returns 404 for non-existent metric" (is (= "Not found." (mt/user-http-request :rasta :get 404 "agent/v1/metric/999999")))))) @@ -193,7 +186,6 @@ (deftest get-table-field-values-test ;; Ensure field values exist for the field we'll test (ensure-fresh-field-values! (mt/id :people :state)) - (testing "Returns field statistics and values with default limit of 30" (let [table-id (mt/id :people) field-id (visible-field-id table-id "State")] @@ -204,18 +196,15 @@ :field_values sequential?}} result)) (is (<= (count (:values result)) 30) "Should apply default limit of 30")))) - (testing "Respects explicit limit parameter" (let [table-id (mt/id :people) field-id (visible-field-id table-id "State")] (is (=? {:value_metadata {:field_values #(= 5 (count %))}} (mt/user-http-request :crowberto :get 200 (format "agent/v1/table/%d/field/%s/values?limit=5" table-id field-id)))))) - (testing "Returns 404 for non-existent table" (is (= "Not found." (mt/user-http-request :crowberto :get 404 "agent/v1/table/999999/field/999999/values")))) - (testing "Returns 404 for non-existent field" (let [table-id (mt/id :people)] (is (= "Field 999999 not found" @@ -263,7 +252,6 @@ (is (= :mbql/query (lib/normalized-query-type decoded))) (is (= (mt/id) (lib/database-id decoded))) (is (= (mt/id :orders) (lib/primary-source-table-id decoded)))))) - (testing "Respects explicit limit operation" (let [table-id (mt/id :orders) response (mt/user-http-request :rasta :post 200 "agent/v2/construct-query" @@ -271,7 +259,6 @@ :operations [["limit" 10]]}) decoded (decode-query response)] (is (= 10 (lib/current-limit decoded))))) - (testing "Returns 404 for non-existent table" (is (= "Not found." (mt/user-http-request :rasta :post 404 "agent/v2/construct-query" @@ -295,7 +282,6 @@ (every? :base_type cols))) :rows (fn [rows] (= 5 (count rows)))}} execute-resp)))) - (testing "Enforces agent query row limit even when query specifies a higher limit" (let [table-id (mt/id :orders) construct-resp (mt/user-http-request :rasta :post 200 "agent/v2/construct-query" @@ -321,11 +307,9 @@ :field_values sequential?}} (mt/user-http-request :rasta :get 200 (format "agent/v1/metric/%d/field/%s/values" (:id metric) field-id))))))) - (testing "Returns 404 for non-existent metric" (is (= "Not found." (mt/user-http-request :rasta :get 404 "agent/v1/metric/999999/field/999999/values")))) - (testing "Returns 404 for non-existent field on metric" (is (= "Field 999999 not found" (mt/user-http-request :rasta :get 404 (format "agent/v1/metric/%d/field/999999/values" (:id metric)))))))) @@ -343,7 +327,6 @@ (let [decoded (decode-query response)] (is (= :mbql/query (lib/normalized-query-type decoded))) (is (= (mt/id) (lib/database-id decoded)))))) - (testing "Returns 404 for non-existent metric" (is (= "Not found." (mt/user-http-request :rasta :post 404 "agent/v2/construct-query" @@ -383,7 +366,6 @@ (testing "with-measures=false (default) does not include measures" (let [table (mt/user-http-request :rasta :get 200 (str "agent/v1/table/" (mt/id :orders)))] (is (nil? (:measures table))))) - (testing "with-measures=true includes measures for the table" (let [table (mt/user-http-request :rasta :get 200 (str "agent/v1/table/" (mt/id :orders) "?with-measures=true"))] @@ -406,7 +388,6 @@ :data {:cols sequential? :rows (fn [rows] (= 5 (count rows)))}} response)))) - (testing "Continuation token returns next page of results when the total limit exceeds the page size" (let [table-id (mt/id :orders) field-id (visible-field-id table-id "ID") @@ -429,14 +410,12 @@ (is (not= (get-in page1 [:data :rows]) (get-in page2 [:data :rows])) "Pages should return different rows"))) - (testing "No continuation_token when all rows are returned" (is (=? {:status "completed" :continuation_token nil?} (mt/user-http-request :rasta :post 202 "agent/v2/query" {:source {:type "table" :id (mt/id :orders)} :operations [["aggregate" ["count"]]]})))) - (testing "Per-page cap limits a single page to 200 rows even when the total limit is higher" (is (=? {:status "completed" :row_count (fn [n] (<= n 200))} @@ -507,7 +486,6 @@ create-resp)) (is (t2/exists? :model/Card :id (:id create-resp))) (t2/delete! :model/Card :id (:id create-resp)))) - (testing "Creates a question with optional fields" (mt/with-temp [:model/Collection {coll-id :id} {:name "Agent Question Collection"}] (let [construct-resp (mt/user-http-request :rasta :post 200 "agent/v2/construct-query" @@ -540,7 +518,6 @@ :dashcard_ids []} resp)) (t2/delete! :model/Dashboard :id (:id resp)))) - (testing "Creates a dashboard with questions" (mt/with-temp [:model/Card {card1-id :id} {:name "DashQ1" :dataset_query (orders-count-query) @@ -564,7 +541,6 @@ (pos? (:size_x %)) (pos? (:size_y %))) dashcards))) (t2/delete! :model/Dashboard :id (:id resp))))) - (testing "Creates a dashboard in a specific collection" (mt/with-temp [:model/Collection {coll-id :id} {:name "Agent Dashboard Collection"}] (let [resp (mt/user-http-request :rasta :post 200 "agent/v1/dashboard" @@ -572,7 +548,6 @@ :collection_id coll-id})] (is (= coll-id (:collection_id resp))) (t2/delete! :model/Dashboard :id (:id resp))))) - (testing "Returns 404 when a question_id does not exist" (mt/user-http-request :rasta :post 404 "agent/v1/dashboard" {:name "Bad Dashboard" diff --git a/test/metabase/analytics/llm_token_usage_test.clj b/test/metabase/analytics/llm_token_usage_test.clj index 084da85dfba6..3be227154be7 100644 --- a/test/metabase/analytics/llm_token_usage_test.clj +++ b/test/metabase/analytics/llm_token_usage_test.clj @@ -105,9 +105,7 @@ (testing "cache counters are untouched when cache fields are omitted" (is (zero? (mt/metric-value system :metabase-metabot/llm-cache-creation-tokens labels))) (is (zero? (mt/metric-value system :metabase-metabot/llm-cache-read-tokens labels))))) - (clear-llm-metrics!) - (testing "positive cache token fields increment their counters" (llm-token-usage/track-prometheus! {:model-id "anthropic/claude-haiku-4-5" :tag "test-tag" @@ -117,9 +115,7 @@ :cache-read-tokens 1600}) (is (= 400.0 (mt/metric-value system :metabase-metabot/llm-cache-creation-tokens labels))) (is (= 1600.0 (mt/metric-value system :metabase-metabot/llm-cache-read-tokens labels)))) - (clear-llm-metrics!) - (testing "zero / nil cache token fields do not increment their counters" (llm-token-usage/track-prometheus! {:model-id "anthropic/claude-haiku-4-5" :tag "test-tag" @@ -159,9 +155,7 @@ (is (= 100.0 (mt/metric-value system :metabase-metabot/llm-input-tokens labels))) (is (= 50.0 (mt/metric-value system :metabase-metabot/llm-output-tokens labels))) (is (= 150.0 (:sum (mt/metric-value system :metabase-metabot/llm-tokens-per-call labels))))))))) - (clear-llm-metrics!) - (testing "Snowplow suppressed when :snowplow false" (snowplow-test/with-fake-snowplow-collector (llm-token-usage/track-token-usage! {:snowplow false @@ -178,9 +172,7 @@ (testing "Prometheus still fires" (is (= 100.0 (mt/metric-value system :metabase-metabot/llm-input-tokens {:model "anthropic/claude-haiku-4-5" :source "test-tag"})))))) - (clear-llm-metrics!) - (testing "Prometheus suppressed when :prometheus false" (mt/with-temporary-setting-values [premium-embedding-token nil analytics-uuid "uuid-prometheus-false"] diff --git a/test/metabase/analytics/prometheus_test.clj b/test/metabase/analytics/prometheus_test.clj index 6465a5d97a11..26e248a744cb 100644 --- a/test/metabase/analytics/prometheus_test.clj +++ b/test/metabase/analytics/prometheus_test.clj @@ -173,7 +173,6 @@ (mt/with-prometheus-system! [_ system] (prometheus/inc! :metabase-email/messages) (is (approx= 1 (mt/metric-value system :metabase-email/messages))))) - (testing "inc with labels is correctly recorded" (mt/with-prometheus-system! [_ system] (prometheus/inc! :metabase-notification/send-ok {:payload-type :notification/card} 1) @@ -185,12 +184,10 @@ (is (thrown-with-msg? RuntimeException #"error when updating metric" (prometheus/dec! :metabase-email/unknown-metric))))) - (testing "dec is recorded for known metrics" (mt/with-prometheus-system! [_ system] (prometheus/dec! :metabase-search/queue-size) (is (approx= -1 (mt/metric-value system :metabase-search/queue-size))))) - (testing "dec with labels is correctly recorded" (mt/with-prometheus-system! [_ system] (prometheus/dec! :metabase-search/engine-active {:engine :default} 1) @@ -229,29 +226,23 @@ (prometheus/inc! :metabase-embedding-iframe-static/response {:status "200"} 0) (prometheus/inc! :metabase-embedding-public/response {:status "200"} 0) (prometheus/inc! :metabase-embedding-simple/response {:status "200"} 0) - ;; Track SDK responses (prometheus/inc! :metabase-sdk/response {:status "200"}) (prometheus/inc! :metabase-sdk/response {:status "404"}) - ;; Track iframe responses (prometheus/inc! :metabase-embedding-iframe/response {:status "200"}) (prometheus/inc! :metabase-embedding-iframe/response {:status "404"}) - ;; Track new embedding responses (prometheus/inc! :metabase-embedding-iframe-full-app/response {:status "200"}) (prometheus/inc! :metabase-embedding-iframe-static/response {:status "200"}) (prometheus/inc! :metabase-embedding-public/response {:status "200"}) (prometheus/inc! :metabase-embedding-simple/response {:status "200"}) - (testing "SDK response metrics are recorded correctly" (is (approx= 1 (mt/metric-value system :metabase-sdk/response {:status "200"}))) (is (approx= 1 (mt/metric-value system :metabase-sdk/response {:status "404"})))) - (testing "iframe response metrics are recorded correctly" (is (approx= 1 (mt/metric-value system :metabase-embedding-iframe/response {:status "200"}))) (is (approx= 1 (mt/metric-value system :metabase-embedding-iframe/response {:status "404"})))) - (testing "new embedding response metrics are recorded correctly" (is (approx= 1 (mt/metric-value system :metabase-embedding-iframe-full-app/response {:status "200"}))) (is (approx= 1 (mt/metric-value system :metabase-embedding-iframe-static/response {:status "200"}))) diff --git a/test/metabase/analytics/quartz_test.clj b/test/metabase/analytics/quartz_test.clj index b8b3cb33e6ac..c589432a11be 100644 --- a/test/metabase/analytics/quartz_test.clj +++ b/test/metabase/analytics/quartz_test.clj @@ -59,7 +59,6 @@ "ERROR" 1} actual-states (into {} (#'analytics.quartz/get-quartz-task-states scheduler))] (is (= expected-states actual-states)))) - (testing "should return only executing count when no triggers exist" (let [scheduler (mock-scheduler [] 2) ; No triggers, 2 executing expected-states {"EXECUTING" 2 @@ -69,7 +68,6 @@ "ERROR" 0} actual-states (into {} (#'analytics.quartz/get-quartz-task-states scheduler))] (is (= expected-states actual-states)))) - (testing "should return empty map when no triggers and no executing jobs" (let [scheduler (mock-scheduler [] 0) ; No triggers, 0 executing expected-states {"EXECUTING" 0 @@ -79,7 +77,6 @@ "ERROR" 0} actual-states (into {} (#'analytics.quartz/get-quartz-task-states scheduler))] (is (= expected-states actual-states)))) - (testing "should handle only triggers without executing jobs" (let [scheduler (mock-scheduler [Trigger$TriggerState/NORMAL Trigger$TriggerState/PAUSED] 0) expected-states {"EXECUTING" 0 @@ -104,7 +101,6 @@ "Counter should increment for successful job with correct tags") (is (= 0.0 (mt/metric-value system :metabase-tasks/quartz-tasks-executed {:status "failed" :job-name job-name})) "Failed counter should remain 0 for the specific job"))) - (testing "Failed execution" (let [ctx (mock-job-execution-context job-name run-time) fail-tags {:status "failed" :job-name (str "DEFAULT." job-name)} @@ -128,7 +124,6 @@ listener (analytics.quartz/create-trigger-listener scheduler)] ; Pass scheduler to listener (.triggerComplete listener nil nil nil) - (testing "Verify gauge values match expected counts" ;; Note: The get-quartz-task-states function calculates these based on the mock scheduler (is (= 1.0 (mt/metric-value system :metabase-tasks/quartz-tasks-states {:state "EXECUTING"}))) diff --git a/test/metabase/analytics/snowplow_test.clj b/test/metabase/analytics/snowplow_test.clj index d80d6cf00621..aeced7bc173f 100644 --- a/test/metabase/analytics/snowplow_test.clj +++ b/test/metabase/analytics/snowplow_test.clj @@ -107,7 +107,6 @@ :application_database (#'snowplow/app-db-type) :application_database_version (#'snowplow/app-db-version)}} (:context (first @*snowplow-collector*)))) - (testing "the created_at should have the format be formatted as RFC3339" (is (valid-datetime-for-snowplow? (get-in (first @*snowplow-collector*) [:context :data :created_at]))))))) @@ -131,13 +130,11 @@ (is (= [{:data {"event" "new_instance_created"} :user-id nil}] (pop-event-data-and-user-id!))) - (let [user-id-str (str (mt/user->id :rasta))] (analytics/track-event! :snowplow/account {:event :new-user-created} 1) (is (= [{:data {"event" "new_user_created"} :user-id "1"}] (pop-event-data-and-user-id!))) - (analytics/track-event! :snowplow/invite {:event :invite-sent :invited-user-id 2 @@ -145,14 +142,12 @@ (is (= [{:data {"invited_user_id" 2, "event" "invite_sent", "source" "admin"} :user-id user-id-str}] (pop-event-data-and-user-id!))) - (analytics/track-event! :snowplow/dashboard {:event :dashboard-created :dashboard-id 1}) (is (= [{:data {"dashboard_id" 1, "event" "dashboard_created"} :user-id user-id-str}] (pop-event-data-and-user-id!))) - (analytics/track-event! :snowplow/dashboard {:event :question-added-to-dashboard :dashboard-id 1 @@ -160,7 +155,6 @@ (is (= [{:data {"dashboard_id" 1, "event" "question_added_to_dashboard", "question_id" 2} :user-id user-id-str}] (pop-event-data-and-user-id!))) - (analytics/track-event! :snowplow/database {:event :database-connection-successful :database :postgres @@ -174,7 +168,6 @@ "source" "admin"} :user-id user-id-str}] (pop-event-data-and-user-id!))) - (analytics/track-event! :snowplow/database {:event :database-connection-failed :database :postgres @@ -182,7 +175,6 @@ (is (= [{:data {"database" "postgres", "event" "database_connection_failed", "source" "admin"} :user-id user-id-str}] (pop-event-data-and-user-id!))) - (analytics/track-event! :snowplow/timeline {:event :new-event-created :source "question" @@ -190,7 +182,6 @@ (is (= [{:data {"event" "new_event_created", "source" "question", "question_id" 1} :user-id user-id-str}] (pop-event-data-and-user-id!))) - (testing "Snowplow events are not sent when tracking is disabled" (mt/with-temporary-setting-values [anon-tracking-enabled false] (analytics/track-event! :snowplow/account {:event :new_instance_created} nil) diff --git a/test/metabase/analytics/stats_test.clj b/test/metabase/analytics/stats_test.clj index 861670b7099a..0f008b1cf4e8 100644 --- a/test/metabase/analytics/stats_test.clj +++ b/test/metabase/analytics/stats_test.clj @@ -433,17 +433,13 @@ (deftest activation-signals-test (mt/with-temp-empty-app-db [_conn :h2] (mdb/setup-db! :create-sample-content? true) - (testing "sufficient-users? correctly counts the number of users within three days of instance creation" (is (false? (@#'stats/sufficient-users? 1))) - (mt/with-temp [:model/User _ {:date_joined (t/plus (t/offset-date-time) (t/days 4))}] (is (false? (@#'stats/sufficient-users? 1)))) - (mt/with-temp [:model/User _ {:date_joined (t/offset-date-time)}] (is (true? (@#'stats/sufficient-users? 1))))) - (testing "sufficient-queries? correctly counts the number of queries" (is (false? (@#'stats/sufficient-queries? 1))) (mt/with-temp [:model/QueryExecution _ query-execution-defaults] @@ -452,26 +448,21 @@ (deftest csv-upload-available-test (mt/with-temp-empty-app-db [_conn :h2] (mdb/setup-db! :create-sample-content? true) - (testing "csv-upload-available? currently detects upload availability based on the current MB version" (mt/with-temp [:model/Database _ {:engine :postgres}] (with-redefs [config/current-major-version (constantly 46) config/current-minor-version (constantly 0)] (is (false? (@#'stats/csv-upload-available?)))) - (with-redefs [config/current-major-version (constantly 47) config/current-minor-version (constantly 1)] (is (true? (@#'stats/csv-upload-available?)))))) - (mt/with-temp [:model/Database _ {:engine :redshift}] (with-redefs [config/current-major-version (constantly 49) config/current-minor-version (constantly 5)] (is (false? (@#'stats/csv-upload-available?)))) - (with-redefs [config/current-major-version (constantly 49) config/current-minor-version (constantly 6)] (is (true? (@#'stats/csv-upload-available?)))))) - ;; If we can't detect the MB version, return nil (with-redefs [config/current-major-version (constantly nil) config/current-minor-version (constantly nil)] @@ -508,7 +499,6 @@ (m/find-first (fn [{key-name :name}] (= key-name :starburst-legacy-impersonation)) (#'stats/snowplow-features-data))))) - (mt/with-temp [(t2/table-name :model/Database) _ {:engine "starburst" :name "starburst-legacy-test" :created_at (t/instant) @@ -525,7 +515,6 @@ (testing "deployment model correctly reports cloud/docker/jar" (with-redefs [premium-features.settings/is-hosted? (constantly true)] (is (= "cloud" (@#'stats/deployment-model)))) - ;; Lets just mock io/file to always return an existing (temp) file, to validate that we're doing a filesystem check ;; to determine whether we're in a Docker container (mt/with-temp-file [mock-file] @@ -533,7 +522,6 @@ (with-redefs [premium-features.settings/is-hosted? (constantly false) io/file (constantly (java.io.File. mock-file))] (is (= "docker" (@#'stats/deployment-model))))) - (with-redefs [premium-features.settings/is-hosted? (constantly false) stats/in-docker? (constantly false)] (is (= "jar" (@#'stats/deployment-model)))))) @@ -585,7 +573,6 @@ all-features @@#'premium-features.settings/premium-features] ;; make sure features are not missing (is (empty? (set/difference all-features included-features-set excluded-features))) - ;; make sure features are not duplicated (is (= (count included-features) (count included-features-set)))))) @@ -651,7 +638,6 @@ (is (= {:library_data 0 :library_metrics 0} (#'stats/library-stats)))) - (testing "with library collections and data" (mt/with-temp [:model/User {user-id :id} {:email "test@example.com" :first_name "Test" diff --git a/test/metabase/api/common_test.clj b/test/metabase/api/common_test.clj index 934c4ada1ffb..0c1f31311433 100644 --- a/test/metabase/api/common_test.clj +++ b/test/metabase/api/common_test.clj @@ -56,19 +56,16 @@ :headers {"Content-Type" "text/plain"}} (binding [api/*current-user* (atom "Cam Saul")] (my-mock-api-fn))))) - (testing "check that 404 is returned otherwise" (is (= (four-oh-four) (-> (my-mock-api-fn) (update-in [:headers "Last-Modified"] string?))))) - (testing "check-404 can return a custom message" (is (= (assoc (four-oh-four) :body "Custom not found.") (-> (mock-api-fn (fn [_] (api/check-404 nil (tru "Custom not found.")))) (update-in [:headers "Last-Modified"] string?))))) - (testing "let-404 should return nil if test fails" (is (= (four-oh-four) (-> (mock-api-fn @@ -76,7 +73,6 @@ (api/let-404 [user nil] {:user user}))) (update-in [:headers "Last-Modified"] string?))))) - (testing "otherwise let-404 should bind as expected" (is (= {:user {:name "Cam"}} ((mw.exceptions/catch-api-exceptions @@ -95,7 +91,6 @@ (deftest parse-multi-values-param-test (testing "single value returns a vector with 1 elem" (is (= [1] (api/parse-multi-values-param "1" parse-long)))) - (testing "multi values a vector as well" (is (= [1 2 3] (api/parse-multi-values-param ["1" "2" "3"] parse-long))))) @@ -112,7 +107,6 @@ (derive :event/write-permission-failure ::permission-failure-event) (derive :event/update-permission-failure ::permission-failure-event) (derive :event/create-permission-failure ::permission-failure-event) - (try (binding [api/*current-user-id* 1] (with-redefs [mi/can-read? (constantly false) diff --git a/test/metabase/api/downloads_exports_test.clj b/test/metabase/api/downloads_exports_test.clj index df34a3b4dc38..72f7eac54703 100644 --- a/test/metabase/api/downloads_exports_test.clj +++ b/test/metabase/api/downloads_exports_test.clj @@ -590,7 +590,6 @@ (testing "The headers also show the Row Totals header" (is (= "Row totals" (last (first result)))))))) - (testing "The Columns Properly indicate the pivot row names." (let [col1 (map first result) col2 (map second result) @@ -808,7 +807,6 @@ ["May, 2016" "144.12" "81.58" "75.09" "90.21" "391"] ["June, 2016" "82.92" "75.53" "83.26" "" "241.71"]] (take 3 pivot)))) - (testing "but only when `qp.settings/enable-pivoted-exports` is true" (mt/with-temporary-setting-values [qp.settings/enable-pivoted-exports false] (let [result (mt/user-http-request :crowberto :post 200 @@ -1619,7 +1617,6 @@ val-unscaled (Double/parseDouble (first (second result-unscaled)))] (is (= val-scaled (* val-unscaled 2.13))))) - (testing "for json" (let [result-scaled (card-download card-scaled {:export-format :json :format-rows true}) result-unscaled (card-download card-unscaled {:export-format :json :format-rows true}) @@ -1737,7 +1734,6 @@ ["3" "" "" "35.39" "35.39"] ["Grand totals" "" "53.98" "" "55.7464"]] (rest result)))))) - (mt/dataset test-data (mt/with-temp [:model/Card source-model {:dataset_query @@ -1795,7 +1791,6 @@ :breakout [$product_id->products.category $product_id->products.vendor] :limit 3})}] - (testing "download" (let [res (card-download pivot-card {:export-format :csv :pivot true :format-rows false}) headers (second res)] diff --git a/test/metabase/api/macros/defendpoint/open_api_test.clj b/test/metabase/api/macros/defendpoint/open_api_test.clj index d19de7fb9433..1e072b56576c 100644 --- a/test/metabase/api/macros/defendpoint/open_api_test.clj +++ b/test/metabase/api/macros/defendpoint/open_api_test.clj @@ -59,7 +59,6 @@ result (#'defendpoint.open-api/path-item "/api/test" form)] (is (true? (:deprecated result))) (is (= "GET /api/test" (:summary result)))))) - (testing "Non-deprecated endpoints do not have deprecated field" (binding [defendpoint.open-api/*definitions* (atom (sorted-map))] (let [form {:method :get @@ -70,7 +69,6 @@ result (#'defendpoint.open-api/path-item "/api/test" form)] (is (nil? (:deprecated result))) (is (= "A normal endpoint." (:description result)))))) - (testing "Deprecated with multipart metadata" (binding [defendpoint.open-api/*definitions* (atom (sorted-map))] (let [form {:method :post diff --git a/test/metabase/api/macros/defendpoint/tools_manifest_test.clj b/test/metabase/api/macros/defendpoint/tools_manifest_test.clj index 94925ba8169c..a2a1cf21a6f6 100644 --- a/test/metabase/api/macros/defendpoint/tools_manifest_test.clj +++ b/test/metabase/api/macros/defendpoint/tools_manifest_test.clj @@ -280,7 +280,6 @@ :destructiveHint false :openWorldHint false}} result)))) - (testing "Scope is included when metadata has :scope" (let [form {:method :get :route {:path "/v1/table/:id"} @@ -292,7 +291,6 @@ :body '(nil)} result (tools-manifest/endpoint->tool-definition "/api/agent" {:form form})] (is (= "agent:table:read" (:scope result))))) - (testing "Scope is omitted when metadata has no :scope" (let [form {:method :get :route {:path "/v1/test"} diff --git a/test/metabase/api/routes/common_test.clj b/test/metabase/api/routes/common_test.clj index 39d36a8adc09..36c4d8394361 100644 --- a/test/metabase/api/routes/common_test.clj +++ b/test/metabase/api/routes/common_test.clj @@ -28,17 +28,14 @@ (is (= api.response/response-forbidden (api-key-enforced-handler (ring.mock/request :get "/anyurl"))))) - (testing "valid apikey, expect 200" (is (= {:success true} (api-key-enforced-handler (request-with-api-key "test-api-key"))))) - (testing "invalid apikey, expect 403" (is (= api.response/response-forbidden (api-key-enforced-handler (request-with-api-key "foobar")))))) - (testing "no apikey is set, expect 403" (doseq [api-key-value [nil ""]] (testing (str "when key is " ({nil "nil" "" "empty"} api-key-value)) diff --git a/test/metabase/api_keys/api_test.clj b/test/metabase/api_keys/api_test.clj index 90dbaa6f474d..673a4917aada 100644 --- a/test/metabase/api_keys/api_test.clj +++ b/test/metabase/api_keys/api_test.clj @@ -279,7 +279,6 @@ (deftest api-keys-can-be-deleted (mt/with-empty-h2-app-db! (is (= [] (mt/user-http-request :crowberto :get 200 "api-key"))) - (let [{id :id} (mt/user-http-request :crowberto :post 200 "api-key" {:group_id (:id (perms-group/all-users)) diff --git a/test/metabase/app_db/aws_iam_test.clj b/test/metabase/app_db/aws_iam_test.clj index ac1ebf81f2d6..81a912dd239c 100644 --- a/test/metabase/app_db/aws_iam_test.clj +++ b/test/metabase/app_db/aws_iam_test.clj @@ -44,7 +44,6 @@ (is (string? user)) (is (int? port)) (is (string? dbname))) - (testing "using broken-out details" (testing "Can create DataSource with AWS IAM enabled" (let [details {:host host @@ -54,16 +53,13 @@ :aws-iam true} datasource (mdb.data-source/broken-out-details->DataSource :postgres details)] (is (instance? javax.sql.DataSource datasource)) - (testing "Can establish connection using AWS IAM" (is (true? (get-connection-and-close! datasource))))))) - (testing "using uri" (testing "Can create DataSource with AWS IAM enabled" (let [datasource (mdb.data-source/raw-connection-string->DataSource uri nil nil nil true)] (is (instance? javax.sql.DataSource datasource)) - (testing "Can establish connection using AWS IAM" (is (true? (get-connection-and-close! datasource)))))))) (log/info "Skipping test: MB_POSTGRES_AWS_IAM_TEST not set"))) @@ -85,10 +81,8 @@ (is (string? user)) (is (int? port)) (is (string? dbname))) - (testing "SSL certificate is configured" (is (string? ssl-cert))) - (testing "using broken-out details" (testing "Can create DataSource with AWS IAM enabled" (let [details {:host host @@ -99,16 +93,13 @@ :aws-iam true} datasource (mdb.data-source/broken-out-details->DataSource :mysql details)] (is (instance? javax.sql.DataSource datasource)) - (testing "Can establish connection using AWS IAM" (is (true? (get-connection-and-close! datasource))))))) - (testing "using uri" (testing "Can create DataSource with AWS IAM enabled" (let [datasource (mdb.data-source/raw-connection-string->DataSource uri nil nil nil true)] (is (instance? javax.sql.DataSource datasource)) - (testing "Can establish connection using AWS IAM" (is (true? (get-connection-and-close! datasource)))))))) (log/info "Skipping test: MB_MYSQL_AWS_IAM_TEST not set")))) diff --git a/test/metabase/app_db/cluster_lock_test.clj b/test/metabase/app_db/cluster_lock_test.clj index 651bcd5ae997..bd006b9f5821 100644 --- a/test/metabase/app_db/cluster_lock_test.clj +++ b/test/metabase/app_db/cluster_lock_test.clj @@ -144,7 +144,6 @@ (when (not= (mdb/db-type) :h2) ;; Ensure that we are creating the lock for the first time (t2/delete! :metabase_cluster_lock :lock_name (u/qualified-name ::race-test-lock)) - (let [results (atom []) result! (partial swap! results conj) thread! (fn [id ^CountDownLatch start-latch ^CountDownLatch end-latch thunk] @@ -165,7 +164,6 @@ b-started (CountDownLatch. 1) a-ended (CountDownLatch. 1) b-ended (CountDownLatch. 1)] - (thread! :a a-started a-ended #(when-not (.await b-started 1 TimeUnit/SECONDS) (throw (ex-info "B did not start while A was still running" {:reason :timeout})))) diff --git a/test/metabase/app_db/connection_test.clj b/test/metabase/app_db/connection_test.clj index 62400e286890..c1b1ad701955 100644 --- a/test/metabase/app_db/connection_test.clj +++ b/test/metabase/app_db/connection_test.clj @@ -54,18 +54,15 @@ (is (user-exists? user-1)) (is (not (user-exists? user-2))) (throw transaction-exception))))) - (testing "top-level transaction aborted" (is (not (user-exists? user-1))) (is (not (user-exists? user-2)))) - (testing "make sure we set autocommit back after the transaction" (t2/with-connection [^java.sql.Connection conn] (t2/with-transaction [_t-conn conn] ;; dummy op (is (false? (.getAutoCommit conn)))) (is (true? (.getAutoCommit conn))))) - (testing "throw error when trying to create nested transaction when nested-transaction-rule=:prohibit" (t2/with-connection [conn] (t2/with-transaction [t-conn conn] @@ -73,7 +70,6 @@ clojure.lang.ExceptionInfo #"Attempted to create nested transaction with :nested-transaction-rule set to :prohibit" (t2/with-transaction [_ t-conn {:nested-transaction-rule :prohibit}])))))) - (testing "reuse transaction when creating nested transaction with nested-transaction-rule=:ignore" (is (not (user-exists? user-1))) (try @@ -100,7 +96,6 @@ (when-not (is-transaction-exception? e) (throw e)))) (is (not (user-exists? user-1)))) - (testing "nested transaction anomalies -- this is not desired behavior" (testing "commit and rollback" (try diff --git a/test/metabase/app_db/custom_migrations/metrics_v2_test.clj b/test/metabase/app_db/custom_migrations/metrics_v2_test.clj index 7898841fe889..1450820ffb1a 100644 --- a/test/metabase/app_db/custom_migrations/metrics_v2_test.clj +++ b/test/metabase/app_db/custom_migrations/metrics_v2_test.clj @@ -224,7 +224,6 @@ (let [query (normalized-query original-query)] (is (query-validator query) (me/humanize (query-explainer query))))) - (testing "forward migration" (migrate!) (let [migration-coll (t2/select-one :collection :name "Migrated Metrics v1") @@ -243,7 +242,6 @@ (is (= original-query (:dataset_query_metrics_v2_migration_backup rewritten-card))) (is (query-validator rewritten-query)) (is (= (-> metric-cards first :id) (get-in rewritten-query [:aggregation 1 1]))))) - (testing "rollback" (migrate! :down 50) (let [migtation-coll (t2/select-one :collection :name "Migrated Metrics v1") diff --git a/test/metabase/app_db/custom_migrations_test.clj b/test/metabase/app_db/custom_migrations_test.clj index a67cee82aa9a..461123d637e0 100644 --- a/test/metabase/app_db/custom_migrations_test.clj +++ b/test/metabase/app_db/custom_migrations_test.clj @@ -443,13 +443,11 @@ :row 0} {:id tab1-card2-id :row 2} - ;; tab 2 {:id tab2-card1-id :row 8} {:id tab2-card2-id :row 12} - ;; tab 3 {:id tab4-card1-id :row 14} @@ -487,7 +485,6 @@ :model_id 1 :user_id user-id :timestamp :%now}))] - (migrate!) (testing "forward migration migrate correclty" (is (= [{:row 15 :col 0 :size_x 16 :size_y 8} @@ -530,7 +527,6 @@ :model_id 1 :user_id user-id :timestamp :%now}))] - (migrate!) (testing "forward migration migrate correclty and ignore failures" (is (= [{:id 1 :row 0 :col 0 :size_x 5 :size_y 4} @@ -610,17 +606,14 @@ (deftest ^:mb/old-migrations-test migrated-grid-18-to-24-stretch-test (let [migrated-to-18 (map @#'custom-migrations/migrate-dashboard-grid-from-18-to-24 big-random-dashboard-cards) rollbacked-to-24 (map @#'custom-migrations/migrate-dashboard-grid-from-24-to-18 migrated-to-18)] - (testing "make sure the initial arry is good to start with" (is (true? (no-cards-are-out-of-grid-and-has-size-0? big-random-dashboard-cards 18))) (is (true? (no-cards-are-overlap? big-random-dashboard-cards)))) - (testing "migrates to 24" (testing "shouldn't have any cards out of grid" (is (true? (no-cards-are-out-of-grid-and-has-size-0? migrated-to-18 24)))) (testing "shouldn't have overlapping cards" (is (true? (no-cards-are-overlap? migrated-to-18))))) - (testing "rollbacked to 18" (testing "shouldn't have any cards out of grid" (is (true? (no-cards-are-out-of-grid-and-has-size-0? rollbacked-to-24 18)))) @@ -1116,7 +1109,6 @@ (:settings (t2/query-one {:select [:settings] :from [:metabase_database] :where [[:= :id success-id]]}))))))) - (testing "the options is merged into settings correctly" (is (= {:persist-models-enabled true :database-enable-actions true} @@ -1127,13 +1119,11 @@ (testing "even when settings is empty" (is (= {:persist-models-enabled true} (t2/select-one-fn :settings :model/Database options-empty-settings-id))))) - (testing "nil or empty options doesn't break migration" (is (= {:database-enable-actions true} (t2/select-one-fn :settings :model/Database nil-options-id))) (is (= {:database-enable-actions true} (t2/select-one-fn :settings :model/Database empty-options-id))))) - (testing "rollback migration" (migrate! :down 46) (testing "the persist-models-enabled is assoced back to options" @@ -1143,7 +1133,6 @@ (is (= {:options nil :settings {:database-enable-actions true}} (t2/select-one [:model/Database :settings :options] empty-options-id)))) - (testing "if settings doesn't have :persist-models-enabled, then options is empty map"))))))] (do-test false) (encryption-test/with-secret-key "dont-tell-anyone-about-this" @@ -1553,14 +1542,12 @@ sso-expected-mapping {"group-mapping-a" [(inc admin-group-id)] "group-mapping-b" [(inc admin-group-id) (+ 2 admin-group-id)]} ldap-expected-mapping {"dc=metabase,dc=com" [(inc admin-group-id)]}] - (testing "Remove admin from group mapping for LDAP, SAML, JWT if they are enabled" (with-ldap-and-sso-configured! ldap-group-mappings sso-group-mappings (#'custom-migrations/migrate-remove-admin-from-group-mapping-if-needed) (is (= ldap-expected-mapping (get-json-setting :ldap-group-mappings))) (is (= sso-expected-mapping (get-json-setting :jwt-group-mappings))) (is (= sso-expected-mapping (get-json-setting :saml-group-mappings))))) - (testing "remove admin from group mapping for LDAP, SAML, JWT even if they are disabled" (with-ldap-and-sso-configured! ldap-group-mappings sso-group-mappings (mt/with-temporary-raw-setting-values @@ -1571,7 +1558,6 @@ (is (= ldap-expected-mapping (get-json-setting :ldap-group-mappings))) (is (= sso-expected-mapping (get-json-setting :jwt-group-mappings))) (is (= sso-expected-mapping (get-json-setting :saml-group-mappings)))))) - (testing "Don't remove admin group if `ldap-sync-admin-group` is enabled" (with-ldap-and-sso-configured! ldap-group-mappings sso-group-mappings (mt/with-temporary-raw-setting-values @@ -1587,11 +1573,9 @@ ;; 0 because we removed them and fresh db won't trigger any (is (= 0 (t2/count :data_migrations))) (migrate!)) - (testing "no data_migrations table after v.48.00-024" (is (thrown? ExceptionInfo (t2/count :data_migrations)))) - (testing "rollback causes all known data_migrations to reappear" (migrate! :down 47) ;; 34 because there was a total of 34 data migrations (which are filled on rollback) @@ -1629,18 +1613,15 @@ (is (true? (set/subset? (set (#'custom-migrations/db-type->to-unified-columns db-type)) (table-and-column-of-type datetime-type))))) - (testing "all of our time columns are now converted to timestamp-tz type, only changelog tables are intact" (migrate!) (is (= #{[:databasechangelog :dateexecuted false] [:databasechangeloglock :lockgranted true]} (set (table-and-column-of-type datetime-type))))) - (testing "downgrade should revert all converted columns to its original type" (migrate! :down 48) (is (true? (set/subset? (set (#'custom-migrations/db-type->to-unified-columns db-type)) (table-and-column-of-type datetime-type))))) - ;; this is a weird behavior on mariadb that I can only find on CI, but it's nice to have this test anw (testing "not nullable timestamp column should not have extra on update" (let [user-id (t2/insert-returning-pk! :core_user {:first_name "Howard" @@ -1692,14 +1673,12 @@ (is (not (contains? card-revision-object "type")))) (testing "has dataset" (is (contains? card-revision-object "dataset"))))) - (testing "after migration card revisions should have type" (migrate!) (let [card-revision-object (t2/select-one-fn (comp json/decode :object) :revision card-revision-id) model-revision-object (t2/select-one-fn (comp json/decode :object) :revision model-revision-id)] (is (= "question" (get card-revision-object "type"))) (is (= "model" (get model-revision-object "type"))))) - (testing "rollback should remove type and keep dataset" (migrate! :down 48) (let [card-revision-object (t2/select-one-fn (comp json/decode :object) :revision card-revision-id) @@ -1770,13 +1749,11 @@ (testing "sanity check that the schedule exists" (is (= (#'task.sync-databases-test/all-db-sync-triggers-name db) (#'task.sync-databases-test/query-all-db-sync-triggers-name db))))) - (migrate!) (testing "default options and scan with manual schedules should have scan field values" (doseq [db db-with-scan-fv] (is (= (#'task.sync-databases-test/all-db-sync-triggers-name db) (#'task.sync-databases-test/query-all-db-sync-triggers-name db))))) - (testing "never scan and on demand should not have scan field values" (doseq [db (t2/select :model/Database :id [:in (map :id db-without-scan-fv)])] (is (= #{(#'api.database-test/sync-and-analyze-trigger-name db)} @@ -1801,7 +1778,6 @@ :where [:= :id db-id]})))] (testing "sanity check that db details is encrypted" (is (true? (encryption/possibly-encrypted-string? (db-detail))))) - (testing "after migrate up, db details should still be encrypted" (migrate!) (is (true? (encryption/possibly-encrypted-string? (db-detail))))) @@ -1841,7 +1817,6 @@ (testing "migrate down will remove init-send-pulse-triggers job, send-pulse job and send-pulse triggers" (migrate! :down 49) (is (= #{} (scheduler-job-keys)))) - (testing "the init-send-pulse-triggers job should be re-run after migrate up" (migrate!) ;; we redefine this so quartz triggers that run on different threads use the same db connection as this test @@ -2049,13 +2024,10 @@ (t2/insert! :setting [{:key "enable-query-caching", :value (encryption/maybe-encrypt "true")} {:key "query-caching-ttl-ratio", :value (encryption/maybe-encrypt "100")} {:key "query-caching-min-ttl", :value (encryption/maybe-encrypt "123")}])) - (testing "Values were indeed encrypted" (is (not= "true" (t2/select-one-fn :value :setting :key "enable-query-caching")))) - (encryption-test/with-secret-key "whateverwhatever" (migrate!)) - (testing "But not anymore" (is (= "true" (t2/select-one-fn :value :setting :key "enable-query-caching"))) (is (= "100" (t2/select-one-fn :value :setting :key "query-caching-ttl-ratio"))) @@ -2213,7 +2185,6 @@ (is (false? (sample-content-created?))) (migrate!) (is (= create? (sample-content-created?)))) - (when (true? create?) (testing "The Examples collection has permissions set to grant read-write access to all users" (let [id (t2/select-one-pk :model/Collection :is_sample true)] @@ -2596,7 +2567,6 @@ :channel_type "email" :details (json/encode {:emails ["test@test.com"]}) :enabled true})))] - (testing "after migration" (migrate!) (testing "pulse is migrated to notification" @@ -2609,16 +2579,13 @@ :active true :creator_id user-id} (select-keys notification [:payload_type :active :creator_id]))) - (is (= {:card_id card-id :send_once false :send_condition "has_result"} (select-keys notification-card [:card_id :send_once :send_condition]))) - (is (= {:type "notification-subscription/cron" :cron_schedule "0 0 18 * * ? *"} (select-keys subscription [:type :cron_schedule]))) - (is (= {:channel_type "channel/email"} (select-keys handler [:channel_type]))) (is (= {:type "notification-recipient/raw-value" @@ -2627,7 +2594,6 @@ (is (= {:type "notification-recipient/raw-value" :details "{\"value\":\"test@test.com\"}"} (select-keys recipient [:type :details])))))) - (testing "after downgrade" (migrate! :down 52) (is (zero? (t2/count :notification :payload_type "notification/card"))))))))) diff --git a/test/metabase/app_db/env_test.clj b/test/metabase/app_db/env_test.clj index 1c4570e2b963..41c3b8df5fc1 100644 --- a/test/metabase/app_db/env_test.clj +++ b/test/metabase/app_db/env_test.clj @@ -16,7 +16,6 @@ (is (= (mdb.data-source/raw-connection-string->DataSource "jdbc:postgresql://metabase?user=cam&password=1234") (#'mdb.env/env->DataSource :postgres {:mb-db-connection-uri "postgres://metabase?user=cam&password=1234"}) (#'mdb.env/env->DataSource :postgres {:mb-db-connection-uri "postgres://metabase?user=cam&password=1234", :mb-db-user "", :mb-db-pass ""}))) - (testing "Raw connection string should support separate username and/or password (#20122)" (testing "username and password" (is (= (mdb.data-source/raw-connection-string->DataSource "jdbc:postgresql://metabase" "cam" "1234" nil) diff --git a/test/metabase/app_db/force_migration_test.clj b/test/metabase/app_db/force_migration_test.clj index 54558d07dd9d..a23fdc5480ab 100644 --- a/test/metabase/app_db/force_migration_test.clj +++ b/test/metabase/app_db/force_migration_test.clj @@ -47,13 +47,10 @@ (when (= driver/*driver* :h2) (.acquireLock lock-service)) (db.setup/migrate! data-source :force) - (testing "Make sure the migrations that intended to succeed are succeed" (is (= ["1" "2" "5"] (t2/select-pks-vec (@#'liquibase/changelog-table-name conn) {:order-by [:dateexecuted :id]})))) - (testing "the custom migration that fails doesn't commit its operation" (is (nil? (t2/select-one :ancient_civilization :name "Greek")))) - (testing "the custom migration that success will persists it result successfully" (is (some? (t2/select-one :ancient_civilization :name "Egypt"))))))))) diff --git a/test/metabase/app_db/internal_user_test.clj b/test/metabase/app_db/internal_user_test.clj index 22c36da4fbe8..63f940f983af 100644 --- a/test/metabase/app_db/internal_user_test.clj +++ b/test/metabase/app_db/internal_user_test.clj @@ -25,7 +25,6 @@ (is (not-any? (comp #{internal-user-email} :email) data))) (testing "does not count the internal user" (is (= total (count data)))))) - (testing "User Endpoints with :id" (doseq [[method endpoint status-code] [[:put "user/:id" 400] [:put "user/:id/reactivate" 400] diff --git a/test/metabase/app_db/liquibase_test.clj b/test/metabase/app_db/liquibase_test.clj index ee2c5bc8dad6..de59d45fc74b 100644 --- a/test/metabase/app_db/liquibase_test.clj +++ b/test/metabase/app_db/liquibase_test.clj @@ -59,19 +59,14 @@ (liquibase/with-liquibase [liquibase conn] (let [table-name (liquibase/changelog-table-name liquibase)] (.update liquibase "") - (liquibase/consolidate-liquibase-changesets! conn liquibase) - (testing "makes sure the change log filename are correctly set" (is (= (set (mdb.test-util/liquibase-file->included-ids "liquibase_legacy_migrations.yaml" driver/*driver* conn)) (t2/select-fn-set :id table-name :filename "migrations/000_legacy_migrations.yaml"))) - (is (= (set (mdb.test-util/liquibase-file->included-ids "migrations/001_update_migrations.yaml" driver/*driver* conn)) (t2/select-fn-set :id table-name :filename "migrations/001_update_migrations.yaml"))) - (is (= [] (remove #(str/starts-with? % "v56.") (t2/select-fn-set :id table-name :filename "migrations/056_update_migrations.yaml")))) - (is (= (t2/select-fn-set :id table-name) (set (mdb.test-util/all-liquibase-ids true driver/*driver* conn))))))))))) @@ -164,19 +159,15 @@ (mt/with-temp-empty-app-db [conn driver/*driver*] (liquibase/with-liquibase [liquibase conn] (.update liquibase "") - (let [actual-latest-applied-version (liquibase/latest-applied-major-version conn (.getDatabase liquibase)) actual-latest-available-version (liquibase/latest-available-major-version liquibase)] (testing "Can downgrade and re-upgrade version" (liquibase/rollback-major-version! conn liquibase false (dec actual-latest-available-version)) (is (= (dec actual-latest-applied-version) (liquibase/latest-applied-major-version conn (.getDatabase liquibase)))) - (liquibase/rollback-major-version! conn liquibase false (- actual-latest-available-version 2)) (is (= (- actual-latest-available-version 2) (liquibase/latest-applied-major-version conn (.getDatabase liquibase)))) - (.update liquibase "") (is (= actual-latest-applied-version (liquibase/latest-applied-major-version conn (.getDatabase liquibase))))) - (testing "Cannot downgrade when there are changests from a newer version already ran which are not in the changelog file" (with-redefs [liquibase/latest-applied-major-version (constantly (inc actual-latest-applied-version))] (is (thrown-with-msg? ExceptionInfo #"Cannot downgrade.*" diff --git a/test/metabase/app_db/query_test.clj b/test/metabase/app_db/query_test.clj index ebc933bd1eda..a59ac9df2328 100644 --- a/test/metabase/app_db/query_test.clj +++ b/test/metabase/app_db/query_test.clj @@ -96,7 +96,6 @@ (try ;; ensure there is no database detritus to trip us up (t2/delete! :model/Setting search-col search-value) - (let [threads 5 latch (CountDownLatch. threads) thunk (fn [] @@ -109,15 +108,12 @@ results (set (mt/repeat-concurrently threads thunk)) n (count results) latest (t2/select-one :model/Setting search-col search-value)] - (case search-col :key (do (testing "every call returns the same row" (is (= #{latest} results))) - (testing "we never insert any duplicates" (is (= 1 (t2/count :model/Setting search-col search-value)))) - (testing "later calls just return the existing row as well" (is (= latest (thunk))) (is (= 1 (t2/count :model/Setting search-col search-value))))) @@ -126,14 +122,11 @@ (do (testing "there may be race conditions, but we insert at least once" (is (pos? n))) - (testing "we returned the same values that were inserted into the database" (is (= results (set (t2/select :model/Setting search-col search-value))))) - (testing "later calls just return an existing row as well" (is (contains? results (thunk))) (is (= results (set (t2/select :model/Setting search-col search-value)))))))) - ;; Since we couldn't use with-temp, we need to clean up manually. (finally (t2/delete! :model/Setting search-col search-value)))))))) @@ -152,10 +145,8 @@ (try ;; ensure there is no database detritus to trip us up (t2/delete! :model/Setting search-col search-value) - (when already-exists? (t2/insert! :model/Setting search-col search-value other-col other-value)) - (let [threads 5 latch (CountDownLatch. threads) thunk (fn [] @@ -168,25 +159,20 @@ {other-col <>})))) values-set (set (mt/repeat-concurrently threads thunk)) latest (get (t2/select-one :model/Setting search-col search-value) other-col)] - (testing "each update tried to set a different value" (is (= threads (count values-set)))) - ;; Unfortunately updates are not serialized, but we cannot show that without using a model with more ;; than 2 fields. (testing "the row is updated to match the last update call that resolved" (is (not= other-value latest)) (is (contains? values-set latest))) - (when (or (= :key search-col) already-exists?) (is (= 1 (count (t2/select :model/Setting search-col search-value))))) - (testing "After the database is created, it does not create further duplicates" (let [count (t2/count :model/Setting search-col search-value)] (is (pos? count)) (is (empty? (set/intersection values-set (set (mt/repeat-concurrently threads thunk))))) (is (= count (t2/count :model/Setting search-col search-value)))))) - ;; Since we couldn't use with-temp, we need to clean up manually. (finally (t2/delete! :model/Setting search-col search-value))))))))) diff --git a/test/metabase/app_db/schema_migrations_test.clj b/test/metabase/app_db/schema_migrations_test.clj index ac3f9f95b9f2..4024849e70f2 100644 --- a/test/metabase/app_db/schema_migrations_test.clj +++ b/test/metabase/app_db/schema_migrations_test.clj @@ -462,7 +462,6 @@ migrated-to-24)) (is (true? (custom-migrations-test/no-cards-are-overlap? migrated-to-24))) (is (true? (custom-migrations-test/no-cards-are-out-of-grid-and-has-size-0? migrated-to-24 24))))) - ;; TODO: this is commented out temporarily because it flakes for MySQL (metabase#37884) #_(testing "downgrade works correctly" (migrate! :down 46) @@ -540,10 +539,8 @@ (let [collection-id (first (t2/insert-returning-pks! (t2/table-name :model/Collection) {:name "Amazing collection" :slug "amazing_collection" :color "#509EE3"}))] - (testing "Collection should exist and have the color set by the user prior to migration" (is (= "#509EE3" (:color (t2/select-one :model/Collection :id collection-id))))) - (migrate!) (testing "should drop the existing color column" (is (not (contains? (t2/select-one :model/Collection :id collection-id) :color))))))))) @@ -599,7 +596,6 @@ (migrate!) (is (= 1 (t2/count :model/AuditLog))) (is (= 1 (t2/count :activity)))) - (testing "`database_id` and `table_id` are merged into `details`" (is (partial= {:id 1 @@ -632,7 +628,6 @@ (migrate!) (is (= 1 (t2/count :model/AuditLog))) (is (= 1 (t2/count :activity)))) - (testing "`database_id` and `table_id` are inserted into `details`, but not merged with the previous value (H2 limitation)" (is (partial= @@ -719,13 +714,11 @@ (is (partial= original-collections (t2/select-fn-set :name :collection))) (is (= 1 (t2/count :core_user :id 13371338))))] - (check-before) ;; Verify that data is inserted correctly (migrate!) ;; no-op forward migration (check-before) ;; Verify that forward migration did not change data (migrate! :down 47) - ;; Verify that rollback deleted the correct rows (is (= 1 (t2/count :metabase_database))) (is (= 1 (t2/count :collection))) @@ -1167,12 +1160,10 @@ :group_id (u/the-id (perms-group/all-users))}) (t2/insert! :permissions {:object read-coll-path :group_id (u/the-id (perms-group/all-users))}) (t2/insert! :permissions {:object write-coll-path :group_id (u/the-id (perms-group/all-users))}) - (t2/insert! :permissions {:object (perms/collection-readwrite-path both-perms-id) :group_id (u/the-id (perms-group/all-users))}) (t2/insert! :permissions {:object (perms/collection-read-path both-perms-id) :group_id (u/the-id (perms-group/all-users))}) - (migrate!) (testing "the valid permissions objects got updated correctly" (is (= [{:collection_id read-coll-id @@ -1243,7 +1234,6 @@ (is (nil? (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/data-access")))) - (testing "Unrestricted not-native data access for a DB" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1255,7 +1245,6 @@ (is (nil? (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/data-access")))) - (testing "Unrestricted data access for a schema" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1268,7 +1257,6 @@ (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/data-access")))) - (testing "Unrestricted data access for a table" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1281,7 +1269,6 @@ (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/data-access")))) - (testing "Query access to a table" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1294,7 +1281,6 @@ (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/data-access")))) - (testing "Segmented query access to a table - maps to unrestricted data access; sandboxing is determined by the `sandboxes` table" (clear-permissions!) @@ -1308,7 +1294,6 @@ (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/data-access")))) - (testing "No self service database access" (clear-permissions!) (migrate!) @@ -1319,7 +1304,6 @@ (is (nil? (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/data-access")))) - (testing "Granular table permissions" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1337,7 +1321,6 @@ (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-2 :group_id group-id :perm_type "perms/data-access")))) - (testing "Block permissions for a database" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1367,7 +1350,6 @@ (is (= "yes" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id :perm_type "perms/native-query-editing")))) - (testing "Native query editing explicitly allowed" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1376,7 +1358,6 @@ (is (= "yes" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id :perm_type "perms/native-query-editing")))) - (testing "Native query editing not allowed" (clear-permissions!) (migrate!) @@ -1422,7 +1403,6 @@ (is (nil? (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/download-results"))) - (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id :object (format "/download/db/%d/schema/" db-id)}) @@ -1433,7 +1413,6 @@ (is (nil? (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/download-results")))) - (testing "Ten-thousand-rows download access for a DB" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1445,7 +1424,6 @@ (is (nil? (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/download-results"))) - (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id :object (format "/download/limited/db/%d/schema/" db-id)}) @@ -1456,7 +1434,6 @@ (is (nil? (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/download-results")))) - (testing "No download access for a DB" (clear-permissions!) (migrate!) @@ -1466,7 +1443,6 @@ (is (nil? (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/download-results")))) - (testing "One-million-rows download access for a table" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1479,7 +1455,6 @@ (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/download-results")))) - (testing "One-million-rows download access for a table" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1492,7 +1467,6 @@ (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/download-results")))) - (testing "Ten-thousand-rows download access for a table" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1505,7 +1479,6 @@ (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-1 :group_id group-id :perm_type "perms/download-results")))) - (testing "Granular table permissions" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) [{:group_id group-id @@ -1556,7 +1529,6 @@ (is (nil? (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id :group_id group-id :perm_type "perms/manage-table-metadata")))) - (testing "No manage table metadata access for a DB" (clear-permissions!) (migrate!) @@ -1566,7 +1538,6 @@ (is (nil? (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id :group_id group-id :perm_type "perms/manage-table-metadata")))) - (testing "Manage table metadata access for a schema" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1579,7 +1550,6 @@ (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id :group_id group-id :perm_type "perms/manage-table-metadata")))) - (testing "Manage table metadata access for a table" (clear-permissions!) (t2/insert! (t2/table-name :model/Permissions) {:group_id group-id @@ -1610,7 +1580,6 @@ (is (= "yes" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :group_id group-id :perm_type "perms/manage-database")))) - (testing "No manage database permission" (clear-permissions!) (migrate!) @@ -1692,83 +1661,69 @@ :schema_name schema :perm_type perm-type :perm_value perm-value))] - (migrate-up!) (insert-perm! "perms/data-access" "unrestricted") (migrate! :down 49) (is (= [(format "/db/%d/schema/" db-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/data-access" "block") (migrate! :down 49) (is (= [(format "/block/db/%d/" db-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/data-access" "no-self-service") (migrate! :down 49) (is (nil? (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/data-access" "unrestricted" table-id "PUBLIC") (migrate! :down 49) (is (= [(format "/db/%d/schema/PUBLIC/table/%d/" db-id table-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/data-access" "unrestricted" table-id "schema/with\\slashes") (migrate! :down 49) (is (= [(format "/db/%d/schema/schema\\/with\\\\slashes/table/%d/" db-id table-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/data-access" "no-self-service" table-id "PUBLIC") (migrate! :down 49) (is (nil? (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/native-query-editing" "yes") (migrate! :down 49) (is (= [(format "/db/%d/native/" db-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/native-query-editing" "no") (migrate! :down 49) (is (nil? (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/download-results" "one-million-rows") (migrate! :down 49) (is (= [(format "/download/db/%d/" db-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/download-results" "ten-thousand-rows") (migrate! :down 49) (is (= [(format "/download/limited/db/%d/" db-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/download-results" "no") (migrate! :down 49) (is (nil? (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/download-results" "one-million-rows" table-id "PUBLIC") (insert-perm! "perms/download-results" "no" table-id-2 "PUBLIC") (migrate! :down 49) (is (= #{(format "/download/db/%d/schema/PUBLIC/table/%d/" db-id table-id)} (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/download-results" "ten-thousand-rows" table-id "PUBLIC") (insert-perm! "perms/download-results" "no" table-id-2 "PUBLIC") (migrate! :down 49) (is (= #{(format "/download/limited/db/%d/schema/PUBLIC/table/%d/" db-id table-id)} (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - ;; Granular download perms also get limited native download perms if all tables have at least "ten-thousand-rows" (migrate-up!) (insert-perm! "perms/download-results" "one-million-rows" table-id "PUBLIC") @@ -1778,54 +1733,45 @@ (format "/download/limited/db/%d/schema/PUBLIC/table/%d/" db-id table-id-2) (format "/download/limited/db/%d/native/" db-id)} (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/download-results" "no" table-id "PUBLIC") (insert-perm! "perms/download-results" "no" table-id-2 "PUBLIC") (migrate! :down 49) (is (nil? (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/download-results" "one-million-rows" table-id "schema/with\\slashes") (insert-perm! "perms/download-results" "no" table-id-2 "PUBLIC") (migrate! :down 49) (is (= #{(format "/download/db/%d/schema/schema\\/with\\\\slashes/table/%d/" db-id table-id)} (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/manage-table-metadata" "yes") (migrate! :down 49) (is (= [(format "/data-model/db/%d/" db-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/manage-table-metadata" "no") (migrate! :down 49) (is (nil? (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/manage-table-metadata" "yes" table-id "PUBLIC") (migrate! :down 49) (is (= [(format "/data-model/db/%d/schema/PUBLIC/table/%d/" db-id table-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/manage-table-metadata" "yes" table-id "schema/with\\slashes") (migrate! :down 49) (is (= [(format "/data-model/db/%d/schema/schema\\/with\\\\slashes/table/%d/" db-id table-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/manage-table-metadata" "no" table-id "PUBLIC") (migrate! :down 49) (is (nil? (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/manage-database" "yes") (migrate! :down 49) (is (= [(format "/details/db/%d/" db-id)] (t2/select-fn-vec :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/manage-database" "no") (migrate! :down 49) @@ -1923,7 +1869,6 @@ (t2/insert! :setting [{:key "enable-query-caching", :value (encryption/maybe-encrypt "true")} {:key "query-caching-ttl-ratio", :value (encryption/maybe-encrypt "100.4")} {:key "query-caching-min-ttl", :value (encryption/maybe-encrypt "123.4")}]) - ;; the idea here is that `v50.2024-04-12T12:33:09` during execution with partially encrypted data (see ;; `cache-config-migration-test`) instead of throwing an error just silently put zeros in config. If config ;; contains zeros, we assume human did not touch it yet and update with existing (decrypted thanks to @@ -1953,11 +1898,9 @@ :dateexecuted :%now :orderexecuted (inc last-order) :exectype "EXECUTED"}]) - (is (=? {:id "v50.2024-04-12T12:33:09" :orderexecuted pos?} (t2/select-one clog :id "v50.2024-04-12T12:33:09"))) - (migrate!) (is (nil? (t2/select-one clog :id "v50.2024-04-12T12:33:09"))))))) @@ -1999,7 +1942,6 @@ (is (= "query-builder-and-native" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id :perm_type "perms/create-queries")))) - (testing "Unrestricted data access + no native query editing" (clear-permissions!) (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id @@ -2017,7 +1959,6 @@ (is (= "query-builder" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id :perm_type "perms/create-queries")))) - (testing "No self-service data access + no native query editing" (clear-permissions!) (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id @@ -2035,7 +1976,6 @@ (is (= "no" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id :perm_type "perms/create-queries")))) - (testing "Blocked data access + no native query editing" (clear-permissions!) (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id @@ -2053,7 +1993,6 @@ (is (= "no" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id :perm_type "perms/create-queries")))) - (testing "Granular (table-level) data access" (clear-permissions!) (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id @@ -2128,7 +2067,6 @@ (is (= "unrestricted" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id-2 :perm_type "perms/view-data")))) - (testing "No self-service in group 1 and block in group 2" (clear-permissions!) (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id @@ -2146,7 +2084,6 @@ (is (= "blocked" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id-2 :perm_type "perms/view-data")))) - (testing "Granular perms in group 1 and block in group 2" (clear-permissions!) (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id @@ -2173,7 +2110,6 @@ (is (= "blocked" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id-2 :perm_type "perms/view-data")))) - (testing "No self-service in group 1 and impersonation in group 2" (clear-permissions!) (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id @@ -2192,7 +2128,6 @@ (is (= "unrestricted" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id-2 :perm_type "perms/view-data")))) - (testing "Granular perms in group 1 and impersonation in group 2" (clear-permissions!) (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id @@ -2213,7 +2148,6 @@ (is (= "unrestricted" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-2 :group_id group-id-1 :perm_type "perms/view-data")))) - (testing "No self-service in group 1 and sandbox in group 2" (clear-permissions!) (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id @@ -2232,7 +2166,6 @@ (is (= "unrestricted" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id nil :group_id group-id-2 :perm_type "perms/view-data")))) - (testing "Granular perms in group 1 and sandbox in group 2" (clear-permissions!) (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id @@ -2253,9 +2186,7 @@ (is (= "unrestricted" (t2/select-one-fn :perm_value (t2/table-name :model/DataPermissions) :db_id db-id :table_id table-id-2 :group_id group-id-1 :perm_type "perms/view-data"))) - (clear-permissions!) - ;; If the sandbox is for a different table, the group should not get the legacy-no-self-service permission (t2/insert! (t2/table-name :model/DataPermissions) {:db_id db-id :table_id table-id-1 @@ -2310,33 +2241,28 @@ (is (= #{(format "/db/%d/schema/" db-id) (format "/db/%d/native/" db-id)} (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/view-data" "unrestricted") (insert-perm! "perms/create-queries" "query-builder") (migrate! :down 49) (is (= #{(format "/db/%d/schema/" db-id)} (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/view-data" "unrestricted") (insert-perm! "perms/create-queries" "no") (migrate! :down 49) (is (nil? (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/view-data" "legacy-no-self-service") (insert-perm! "perms/create-queries" "no") (migrate! :down 49) (is (nil? (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/view-data" "blocked") (insert-perm! "perms/create-queries" "no") (migrate! :down 49) (is (= #{(format "/block/db/%d/" db-id)} (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id)))) - (testing "Table-level access" (migrate-up!) (insert-perm! "perms/view-data" "unrestricted") @@ -2344,13 +2270,11 @@ (migrate! :down 49) (is (= #{(format "/db/%d/schema/PUBLIC/table/%d/" db-id table-id)} (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/view-data" "unrestricted") (insert-perm! "perms/create-queries" "no" table-id) (migrate! :down 49) (is (nil? (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id)))) - (testing "Impersonated data access" (migrate-up!) (t2/insert-returning-pks! :connection_impersonations {:group_id group-id :db_id db-id :attribute "foo"}) @@ -2360,7 +2284,6 @@ (is (= #{(format "/db/%d/schema/" db-id) (format "/db/%d/native/" db-id)} (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (t2/insert-returning-pks! :connection_impersonations {:group_id group-id :db_id db-id :attribute "foo"}) (insert-perm! "perms/view-data" "unrestricted") @@ -2368,7 +2291,6 @@ (migrate! :down 49) (is (= #{(format "/db/%d/schema/" db-id)} (t2/select-fn-set :object (t2/table-name :model/Permissions) :group_id group-id))) - (migrate-up!) (insert-perm! "perms/view-data" "blocked" table-id) (migrate! :down 49) diff --git a/test/metabase/app_db/schema_migrations_test/impl.clj b/test/metabase/app_db/schema_migrations_test/impl.clj index 51accb566096..72156c2afdaa 100644 --- a/test/metabase/app_db/schema_migrations_test/impl.clj +++ b/test/metabase/app_db/schema_migrations_test/impl.clj @@ -168,7 +168,6 @@ (migrate :up nil)) ([direction] (migrate direction nil)) - ([direction version] (case direction :up @@ -254,14 +253,12 @@ (let [ran (migrations-run conn)] (is (contains? ran "v00.00-000") "start should be included (inclusive)") (is (contains? ran "v45.00-002") "end should be included (inclusive)")))) - (testing "single-item range (start == end)" (with-temp-empty-app-db [conn :h2] (run-migrations-in-range! conn ["v45.00-001" "v45.00-001"]) (let [ran (migrations-run conn)] (is (contains? ran "v45.00-001") "the single migration should be included") (is (not (contains? ran "v45.00-002")) "the next migration should NOT be included")))) - (testing "exclusive end excludes the endpoint" (with-temp-empty-app-db [conn :h2] ;; Run v00.00-000 through v45.00-002 with exclusive end — v45.00-002 should NOT be run @@ -270,7 +267,6 @@ (is (contains? ran "v00.00-000") "start should be included (inclusive by default)") (is (not (contains? ran "v45.00-002")) "end should be excluded (exclusive)") (is (contains? ran "v45.00-001") "migration before end should be included")))) - (testing "exclusive start excludes the start point" (with-temp-empty-app-db [conn :h2] ;; First run all migrations up through v45.00-001 so the DB has the required schema @@ -283,13 +279,11 @@ (is (not (contains? newly-ran "v45.00-001")) "v45.00-001 should NOT be re-run (exclusive start)") (is (contains? newly-ran "v45.00-002") "v45.00-002 should be included (between exclusive start and end)") (is (contains? newly-ran "v45.00-011") "end should be included (inclusive by default)"))))) - (testing "unknown start-id throws" (with-temp-empty-app-db [conn :h2] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Migration ID not found in changelog" (run-migrations-in-range! conn ["v99.bogus-999" "v45.00-002"]))))) - (testing "unknown end-id throws" (with-temp-empty-app-db [conn :h2] (is (thrown-with-msg? clojure.lang.ExceptionInfo diff --git a/test/metabase/app_db/setup_test.clj b/test/metabase/app_db/setup_test.clj index e95e8492dd1e..d3662cd740cc 100644 --- a/test/metabase/app_db/setup_test.clj +++ b/test/metabase/app_db/setup_test.clj @@ -34,7 +34,6 @@ (is (true? (#'mdb.setup/supported-app-db-version? db (merge-with + expected-min-version diff))))) (doseq [diff [{:major -1} {:minor -1} {:patch -1}]] (is (false? (#'mdb.setup/supported-app-db-version? db (merge-with + expected-min-version diff))))))] - (test-supported-versions :h2 {:major 2 :minor 1 :patch 214}) (test-supported-versions :postgres {:major 14 :minor 0 :patch 0}) (test-supported-versions :mysql {:major 8 :minor 0 :patch 0}) @@ -47,11 +46,9 @@ (is (= {:major 18 :minor 3 :patch 0} (#'mdb.setup/parse-db-version "18.3 (Debian 18.3-1.pgdg13+1)"))) (is (= {:major 14 :minor 22 :patch 0} (#'mdb.setup/parse-db-version "14.22 (Debian 14.22-1.pgdg13+1)"))) (is (= {:major 11 :minor 16 :patch 0} (#'mdb.setup/parse-db-version "11.16 (Debian 11.16-1.pgdg90+1)")))) - (testing "Can parse mysql version strings" (is (= {:major 9 :minor 6 :patch 0} (#'mdb.setup/parse-db-version "9.6.0"))) (is (= {:major 8 :minor 0 :patch 45} (#'mdb.setup/parse-db-version "8.0.45")))) - (testing "Can parse mariadb version strings" (is (= {:major 12 :minor 2 :patch 2} (#'mdb.setup/parse-db-version "12.2.2-MariaDB-ubu2404"))))) @@ -98,10 +95,8 @@ (testing "Running setup with `auto-migrate?`=false should pass if no migrations exist which need to be run" (is (= :done (mdb.setup/setup-db! driver/*driver* (mdb.connection/data-source) true false))) - (is (= :done (mdb.setup/setup-db! driver/*driver* (mdb.connection/data-source) false false))))) - (testing "Setting up DB with `auto-migrate?`=false should exit if any migrations exist which need to be run" ;; Use a migration file that intentionally errors with failOnError: false, so that a migration is still unrun ;; when we re-run `setup-db!` @@ -109,7 +104,6 @@ (mt/with-temp-empty-app-db [_conn driver/*driver*] (is (= :done (mdb.setup/setup-db! driver/*driver* (mdb.connection/data-source) true false))) - (is (thrown-with-msg? Exception #"Database requires manual upgrade." @@ -149,14 +143,12 @@ (mt/with-temp-empty-app-db [conn driver/*driver*] ;; migrate to v45 (update-to-changelog-id "v45.00-001" conn) - ;; the latest changeSet in `000_legacy_migrations.yaml` is `v44.00-044`. We can simulate a downgrade to that ;; version by telling Liquibase that's the migrations file. (with-redefs [liquibase/decide-liquibase-file (fn [& _args] "liquibase_legacy_migrations.yaml")] (is (thrown-with-msg? Exception #"You must run `java --add-opens java.base/java.nio=ALL-UNNAMED -jar metabase.jar migrate down` from version 45." (#'mdb.setup/error-if-downgrade-required! (mdb.connection/data-source))))) - ;; check that the error correctly reports the version to run `downgrade` from (update-to-changelog-id "v46.00-001" conn) (with-redefs [liquibase/decide-liquibase-file (fn [& _args] "liquibase_legacy_migrations.yaml")] diff --git a/test/metabase/appearance/settings_test.clj b/test/metabase/appearance/settings_test.clj index eb578b1f4608..2b31922627ca 100644 --- a/test/metabase/appearance/settings_test.clj +++ b/test/metabase/appearance/settings_test.clj @@ -13,47 +13,38 @@ (testing "When whitelabeling is enabled, help-link setting can be set to any valid value" (appearance.settings/help-link! :metabase) (is (= :metabase (appearance.settings/help-link))) - (appearance.settings/help-link! :hidden) (is (= :hidden (appearance.settings/help-link))) - (appearance.settings/help-link! :custom) (is (= :custom (appearance.settings/help-link)))) - (testing "help-link cannot be set to an invalid value" (is (thrown-with-msg? Exception #"Invalid help link option" (appearance.settings/help-link! :invalid))))) - (mt/with-premium-features #{} (testing "When whitelabeling is not enabled, help-link setting cannot be set, and always returns :metabase" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Setting help-link is not enabled because feature :whitelabel is not available" (appearance.settings/help-link! :hidden))) - (is (= :metabase (appearance.settings/help-link))))))) (deftest validate-help-url-test (testing "validate-help-url accepts valid URLs with HTTP or HTTPS protocols" (is (nil? (#'appearance.settings/validate-help-url "http://www.metabase.com"))) (is (nil? (#'appearance.settings/validate-help-url "https://www.metabase.com")))) - (testing "validate-help-url accepts valid mailto: links" (is (nil? (#'appearance.settings/validate-help-url "mailto:help@metabase.com")))) - (testing "validate-help-url rejects malformed URLs and URLs with invalid protocols" ;; Since validate-help-url calls `u/url?` to validate URLs, we don't need to test all possible malformed URLs here. (is (thrown-with-msg? Exception #"Please make sure this is a valid URL" (#'appearance.settings/validate-help-url "asdf"))) - (is (thrown-with-msg? Exception #"Please make sure this is a valid URL" (#'appearance.settings/validate-help-url "ftp://metabase.com")))) - (testing "validate-help-url rejects mailto: links with invalid email addresses" (is (thrown-with-msg? Exception @@ -65,33 +56,27 @@ (testing "When whitelabeling is enabled, help-link-custom-destination can be set to valid URLs" (appearance.settings/help-link-custom-destination! "http://www.metabase.com") (is (= "http://www.metabase.com" (appearance.settings/help-link-custom-destination))) - (appearance.settings/help-link-custom-destination! "mailto:help@metabase.com") (is (= "mailto:help@metabase.com" (appearance.settings/help-link-custom-destination)))) - (testing "help-link-custom-destination cannot be set to invalid URLs" (is (thrown-with-msg? Exception #"Please make sure this is a valid URL" (appearance.settings/help-link-custom-destination! "asdf"))) - (is (thrown-with-msg? Exception #"Please make sure this is a valid URL" (appearance.settings/help-link-custom-destination! "ftp://metabase.com"))) - (is (thrown-with-msg? Exception #"Please make sure this is a valid URL" (appearance.settings/help-link-custom-destination! "mailto:help@metabase"))))) - (mt/with-premium-features #{} (testing "When whitelabeling is not enabled, help-link-custom-destination cannot be set, and always returns its default" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Setting help-link-custom-destination is not enabled because feature :whitelabel is not available" (appearance.settings/help-link-custom-destination! "http://www.metabase.com"))) - (is (= "https://www.metabase.com/help/premium" (appearance.settings/help-link-custom-destination)))))) (deftest landing-page-setting-test @@ -99,47 +84,36 @@ (testing "should return relative url for valid inputs" (appearance.settings/landing-page! "") (is (= "" (appearance.settings/landing-page))) - (appearance.settings/landing-page! "/") (is (= "/" (appearance.settings/landing-page))) - (appearance.settings/landing-page! "/one/two/three/") (is (= "/one/two/three/" (appearance.settings/landing-page))) - (appearance.settings/landing-page! "no-leading-slash") (is (= "/no-leading-slash" (appearance.settings/landing-page))) - (appearance.settings/landing-page! "/pathname?query=param#hash") (is (= "/pathname?query=param#hash" (appearance.settings/landing-page))) - (appearance.settings/landing-page! "#hash") (is (= "/#hash" (appearance.settings/landing-page))) - (mt/with-temporary-setting-values [site-url "http://localhost"] (appearance.settings/landing-page! "http://localhost/absolute/same-origin") (is (= "/absolute/same-origin" (appearance.settings/landing-page))))) - (testing "landing-page cannot be set to URLs with external origin" (is (thrown-with-msg? Exception #"This field must be a relative URL." (appearance.settings/landing-page! "https://google.com"))) - (is (thrown-with-msg? Exception #"This field must be a relative URL." (appearance.settings/landing-page! "sms://?&body=Hello"))) - (is (thrown-with-msg? Exception #"This field must be a relative URL." (appearance.settings/landing-page! "https://localhost/test"))) - (is (thrown-with-msg? Exception #"This field must be a relative URL." (appearance.settings/landing-page! "mailto:user@example.com"))) - (is (thrown-with-msg? Exception #"This field must be a relative URL." @@ -151,17 +125,14 @@ (testing "When whitelabeling is enabled, show-metabase-links setting can be set to boolean" (appearance.settings/show-metabase-links! true) (is (true? (appearance.settings/show-metabase-links))) - (appearance.settings/show-metabase-links! false) (is (= false (appearance.settings/show-metabase-links))))) - (mt/with-premium-features #{} (testing "When whitelabeling is not enabled, show-metabase-links setting cannot be set, and always returns true" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Setting show-metabase-links is not enabled because feature :whitelabel is not available" (appearance.settings/show-metabase-links! true))) - (is (true? (appearance.settings/show-metabase-links))))))) (deftest loading-message-test @@ -169,11 +140,9 @@ (testing "Loading message can be set by env var" (mt/with-temp-env-var-value! [mb-loading-message "running-query"] (is (= :running-query (appearance.settings/loading-message))))) - (testing "Default value is returned if loading message set via env var to an unsupported keyword value" (mt/with-temp-env-var-value! [mb-loading-message "unsupported enum value"] (is (= :doing-science (appearance.settings/loading-message))))) - (testing "Setter blocks unsupported values set at runtime" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Loading message set to an unsupported value" diff --git a/test/metabase/audit_app/events/audit_log_test.clj b/test/metabase/audit_app/events/audit_log_test.clj index 59cfaf9dd780..b215c2cdd6ee 100644 --- a/test/metabase/audit_app/events/audit_log_test.clj +++ b/test/metabase/audit_app/events/audit_log_test.clj @@ -495,13 +495,11 @@ :topic :channel-create :model "Channel"} (mt/latest-audit-log-entry :channel-create (:id channel))))) - (testing :event/channel-update (events/publish-event! :event/channel-update {:object (assoc channel :details {:new-detail true} :name "New Name") :previous-object channel}) - (is (= {:model_id (:id channel) :user_id (mt/user->id :rasta) :details {:previous {:name "Test channel"} @@ -545,7 +543,6 @@ :topic :document-create :model "Document"} (mt/latest-audit-log-entry :document-create (:id document))))) - (testing :event/document-update (let [updated-doc (assoc document :name "Updated Document")] (is (= {:object updated-doc @@ -559,7 +556,6 @@ :topic :document-update :model "Document"} (mt/latest-audit-log-entry :document-update (:id document)))))) - (testing :event/document-delete (is (= {:object document} (events/publish-event! :event/document-delete {:object document}))) @@ -589,7 +585,6 @@ :topic :comment-create :model "Comment"} (mt/latest-audit-log-entry :comment-create (:id comment))))) - (testing :event/comment-update (let [updated-comment (assoc comment :content "Updated comment")] (is (= {:object updated-comment @@ -602,7 +597,6 @@ :topic :comment-update :model "Comment"} (mt/latest-audit-log-entry :comment-update (:id comment)))))) - (testing :event/comment-delete (is (= {:object comment} (events/publish-event! :event/comment-delete {:object comment}))) @@ -632,7 +626,6 @@ :topic :transform-create :model "Transform"} (mt/latest-audit-log-entry :transform-create (:id transform))))) - (testing :event/update-transform (let [updated-transform (assoc transform :name "Updated Transform")] (is (= {:object updated-transform @@ -646,7 +639,6 @@ :topic :update-transform :model "Transform"} (mt/latest-audit-log-entry :update-transform (:id transform)))))) - (testing :event/transform-delete (is (= {:object transform} (events/publish-event! :event/transform-delete {:object transform}))) @@ -671,7 +663,6 @@ :topic :glossary-create :model "Glossary"} (mt/latest-audit-log-entry :glossary-create (:id glossary))))) - (testing :event/glossary-update (let [updated-glossary (assoc glossary :term "Updated Term")] (is (= {:object updated-glossary @@ -685,7 +676,6 @@ :topic :glossary-update :model "Glossary"} (mt/latest-audit-log-entry :glossary-update (:id glossary)))))) - (testing :event/glossary-delete (is (= {:object glossary} (events/publish-event! :event/glossary-delete {:object glossary}))) @@ -715,7 +705,6 @@ :topic :remote-sync-import :model "RemoteSyncTask"} (mt/latest-audit-log-entry :remote-sync-import task-id)))) - (testing :event/remote-sync-export (is (= {:object task} (events/publish-event! :event/remote-sync-export {:object task}))) @@ -726,7 +715,6 @@ :topic :remote-sync-export :model "RemoteSyncTask"} (mt/latest-audit-log-entry :remote-sync-export task-id)))) - (testing :event/remote-sync-settings-update (is (= {:object task} (events/publish-event! :event/remote-sync-settings-update {:object task}))) @@ -737,7 +725,6 @@ :topic :remote-sync-settings-update :model "RemoteSyncTask"} (mt/latest-audit-log-entry :remote-sync-settings-update task-id)))) - (testing :event/remote-sync-create-branch (is (= {:object task} (events/publish-event! :event/remote-sync-create-branch {:object task}))) @@ -748,7 +735,6 @@ :topic :remote-sync-create-branch :model "RemoteSyncTask"} (mt/latest-audit-log-entry :remote-sync-create-branch task-id)))) - (testing :event/remote-sync-stash (is (= {:object task} (events/publish-event! :event/remote-sync-stash {:object task}))) diff --git a/test/metabase/audit_app/models/audit_log_test.clj b/test/metabase/audit_app/models/audit_log_test.clj index 8529e01d2cd5..f068833e47d7 100644 --- a/test/metabase/audit_app/models/audit_log_test.clj +++ b/test/metabase/audit_app/models/audit_log_test.clj @@ -56,7 +56,6 @@ :model_id card-id :details {:name "Test card"}} (t2/select-one :model/AuditLog :model_id card-id))))) - (testing "Test that `record-event!` succesfully records basic card events with the user, model, and model ID specified" (mt/with-temp [:model/Card {card-id :id :as card} {:name "Test card"}] (audit-log/record-event! :event/card-create @@ -71,7 +70,6 @@ :model_id card-id :details {:name "Test card"}} (t2/select-one :model/AuditLog :model_id card-id))))) - (testing "Test that `record-event!` records an event with arbitrary data and no model specified" (audit-log/record-event! :event/test-event {:details {:foo "bar"}}) (is (partial= diff --git a/test/metabase/audit_app/settings_test.clj b/test/metabase/audit_app/settings_test.clj index 08c0a204558a..303d23a6d152 100644 --- a/test/metabase/audit_app/settings_test.clj +++ b/test/metabase/audit_app/settings_test.clj @@ -8,17 +8,13 @@ (deftest audit-max-retention-days-test (mt/with-temp-env-var-value! [mb-audit-max-retention-days nil] (is (= 720 (audit.settings/audit-max-retention-days)))) - (mt/with-temp-env-var-value! [mb-audit-max-retention-days 0] (is (= ##Inf (audit.settings/audit-max-retention-days)))) - (mt/with-temp-env-var-value! [mb-audit-max-retention-days 100] (is (= 100 (audit.settings/audit-max-retention-days)))) - ;; Acceptable values have a lower bound of 30 (mt/with-temp-env-var-value! [mb-audit-max-retention-days 1] (is (= 30 (audit.settings/audit-max-retention-days)))) - (is (thrown-with-msg? java.lang.UnsupportedOperationException #"You cannot set audit-max-retention-days" diff --git a/test/metabase/audit_app/task/truncate_audit_tables_test.clj b/test/metabase/audit_app/task/truncate_audit_tables_test.clj index 98afef4c5ed5..4a30c0207d1f 100644 --- a/test/metabase/audit_app/task/truncate_audit_tables_test.clj +++ b/test/metabase/audit_app/task/truncate_audit_tables_test.clj @@ -40,19 +40,16 @@ (#'task.truncate-audit-tables/truncate-audit-tables!) (is (= #{qe1-id qe2-id qe3-id} (t2/select-fn-set :id :model/QueryExecution {:where [:in :id [qe1-id qe2-id qe3-id]]})))) - (testing "When the threshold is 100 days, one row is deleted" (mt/with-temp-env-var-value! [mb-audit-max-retention-days 100] (#'task.truncate-audit-tables/truncate-audit-tables!) (is (= #{qe1-id qe2-id} (t2/select-fn-set :id :model/QueryExecution {:where [:in :id [qe1-id qe2-id qe3-id]]}))))) - (testing "When the threshold is 30 days, two rows are deleted" (mt/with-temp-env-var-value! [mb-audit-max-retention-days 30] (#'task.truncate-audit-tables/truncate-audit-tables!) (is (= #{qe1-id} (t2/select-fn-set :id :model/QueryExecution {:where [:in :id [qe1-id qe2-id qe3-id]]}))))) - (testing "When the threshold set to 1 day, the remaining row is not deleted because the minimum threshold is 30" (mt/with-temp-env-var-value! [mb-audit-max-retention-days 1] (#'task.truncate-audit-tables/truncate-audit-tables!) diff --git a/test/metabase/auth_identity/provider_test.clj b/test/metabase/auth_identity/provider_test.clj index 84100a57b2be..d88a73109f21 100644 --- a/test/metabase/auth_identity/provider_test.clj +++ b/test/metabase/auth_identity/provider_test.clj @@ -15,10 +15,8 @@ (testing "Providers can derive from ::provider/provider" (is (isa? :provider/test-password ::provider/provider)) (is (isa? :provider/test-ldap ::provider/provider))) - (testing "SSO providers can derive from ::provider/create-user-if-not-exists" (is (isa? :provider/test-ldap ::provider/create-user-if-not-exists))) - (testing "Password providers do NOT derive from create-user-if-not-exists" (is (not (isa? :provider/test-password ::provider/create-user-if-not-exists)))))) @@ -44,7 +42,6 @@ (is (= :provider/google (provider/provider-string->keyword "google"))) (is (= :provider/jwt (provider/provider-string->keyword "jwt"))) (is (= :provider/saml (provider/provider-string->keyword "saml")))) - (testing "provider-keyword->string converts keywords to strings" (is (= "password" (provider/provider-keyword->string :provider/password))) (is (= "emailed-secret" (provider/provider-keyword->string :provider/emailed-secret))) @@ -52,7 +49,6 @@ (is (= "google" (provider/provider-keyword->string :provider/google))) (is (= "jwt" (provider/provider-keyword->string :provider/jwt))) (is (= "saml" (provider/provider-keyword->string :provider/saml)))) - (testing "Round-trip conversion works" (doseq [provider-str ["password" "emailed-secret" "ldap" "google" "jwt" "saml"]] (is (= provider-str @@ -70,7 +66,6 @@ {:success? :redirect :redirect-url "https://example.com/oauth" :message "Redirecting to provider"}) - (let [result (provider/login! :provider/test-redirect {:device-info {:device_id "test" :ip_address "127.0.0.1"}})] (is (= :redirect (:success? result))) @@ -78,7 +73,6 @@ (is (= "Redirecting to provider" (:message result))) (is (nil? (:session result))) (is (nil? (:user result)))))) - (testing "login! default implementation handles failure responses" (testing "Returns error response unchanged when authenticate fails" ;; Create a test provider that returns error @@ -88,7 +82,6 @@ {:success? false :error :invalid-credentials :message "Invalid credentials"}) - (let [result (provider/login! :provider/test-error {:email "test@example.com" :password "wrong" @@ -103,10 +96,8 @@ (testing "Success states work correctly" (testing "Success state: true" (is (true? (:success? {:success? true :user-id 123})))) - (testing "Success state: :redirect" (is (= :redirect (:success? {:success? :redirect :redirect-url "https://example.com"})))) - (testing "Success state: false" (is (false? (:success? {:success? false :error :invalid-credentials})))))) @@ -114,10 +105,8 @@ (testing "Multimethod dispatch works with provider hierarchy" ;; Create a test provider that inherits from ::provider/provider (derive :provider/test-custom ::provider/provider) - (testing "Custom provider inherits default validate implementation" (is (nil? (provider/validate :provider/test-custom {:credentials {:foo "bar"}})))) - (testing "Custom provider inherits default authenticate implementation" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -133,12 +122,10 @@ {:success? true :user-id 123 :provider-id email}) - (let [result (provider/authenticate :provider/test-with-provider-id {:email "user@example.com" :password "secret"})] (is (true? (:success? result))) (is (= 123 (:user-id result))) (is (= "user@example.com" (:provider-id result)))))) - (testing "authenticate docstring documents :provider-id return value" (let [docstring (-> #'provider/authenticate meta :doc)] (is (string? docstring)) @@ -156,14 +143,12 @@ :last_name "User" :sso_source :test} :provider-id "newuser@example.com"}) - ;; Test that the :around method merges :provider-id into :user-data (let [auth-result (provider/authenticate :provider/test-provider-id-flow {:token "test"})] (is (true? (:success? auth-result))) (is (= "newuser@example.com" (:provider-id auth-result))) (is (contains? (:user-data auth-result) :email)) (is (not (contains? (:user-data auth-result) :provider-id))) - ;; Simulate what login! :around does (let [merged-request (cond-> auth-result (and (:provider-id auth-result) (:user-data auth-result)) @@ -181,7 +166,6 @@ :user_id 123 :provider "test-expired" :expires_at (t/minus (t/offset-date-time) (t/hours 1))}}) - (let [result (provider/authenticate :provider/test-expired {:email "test@example.com"})] (is (false? (:success? result)) "Authentication should fail for expired auth-identity") @@ -201,7 +185,6 @@ :user_id 123 :provider "test-not-expired" :expires_at (t/plus (t/offset-date-time) (t/hours 24))}}) - (let [result (provider/authenticate :provider/test-not-expired {:email "test@example.com"})] (is (true? (:success? result)) "Authentication should succeed for non-expired auth-identity") @@ -221,7 +204,6 @@ :user_id 123 :provider "test-no-expiration" :expires_at nil}}) - (let [result (provider/authenticate :provider/test-no-expiration {:email "test@example.com"})] (is (true? (:success? result)) "Authentication should succeed when no expiration set") @@ -239,7 +221,6 @@ :user-data {:email "newuser@example.com" :first_name "New" :last_name "User"}}) - (let [result (provider/authenticate :provider/test-no-auth-identity {:token "abc123"})] (is (true? (:success? result)) "Authentication should succeed without auth-identity") @@ -256,7 +237,6 @@ {:success? false :error :invalid-credentials :message "Invalid password"}) - (let [result (provider/authenticate :provider/test-auth-failure {:email "test@example.com" :password "wrong"})] (is (false? (:success? result)) "Authentication should fail") diff --git a/test/metabase/bookmarks/api_test.clj b/test/metabase/bookmarks/api_test.clj index 8a4ee1c93059..4842b04f0cdf 100644 --- a/test/metabase/bookmarks/api_test.clj +++ b/test/metabase/bookmarks/api_test.clj @@ -131,19 +131,16 @@ (is (= (u/the-id document) (->> (mt/user-http-request :rasta :post 200 (str "bookmark/document/" (u/the-id document))) :document_id)))) - (testing "document appears in bookmark list" (let [result (mt/user-http-request :rasta :get 200 "bookmark") document-bookmark (first (filter #(= (:type %) "document") result))] (is (some? document-bookmark)) (is (= "Test Document" (:name document-bookmark))) (is (= (u/the-id document) (:item_id document-bookmark))))) - (testing "can delete document bookmark" (mt/user-http-request :rasta :delete 204 (str "bookmark/document/" (u/the-id document))) (is (empty? (filter #(= (:type %) "document") (mt/user-http-request :rasta :get 200 "bookmark"))))) - (testing "document bookmarks are included in ordering" (mt/with-temp [:model/Card card {:name "Test Card"}] (mt/with-model-cleanup [:model/BookmarkOrdering] diff --git a/test/metabase/channel/api/channel_test.clj b/test/metabase/channel/api/channel_test.clj index 4230fe9e190a..72dc10848821 100644 --- a/test/metabase/channel/api/channel_test.clj +++ b/test/metabase/channel/api/channel_test.clj @@ -25,12 +25,10 @@ (testing "can get the channel" (is (=? default-test-channel (mt/user-http-request :crowberto :get 200 (str "channel/" (:id channel)))))) - (testing "can update channel name" (mt/user-http-request :crowberto :put 200 (str "channel/" (:id channel)) {:name "New Name"}) (is (= "New Name" (t2/select-one-fn :name :model/Channel (:id channel))))) - (testing "can update channel details even if it fails to connect" (mt/user-http-request :crowberto :put 200 (str "channel/" (:id channel)) {:details {:return-type "return-value" @@ -38,12 +36,10 @@ (is (= {:return-type "return-value" :return-value false} (t2/select-one-fn :details :model/Channel (:id channel))))) - (testing "can update channel description" (mt/user-http-request :crowberto :put 200 (str "channel/" (:id channel)) {:description "New description"}) (is (= "New description" (t2/select-one-fn :description :model/Channel (:id channel))))) - (testing "can disable a channel" (mt/user-http-request :crowberto :put 200 (str "channel/" (:id channel)) {:active false}) @@ -79,7 +75,6 @@ (testing "return active channels only" (is (= [(update chn-1 :type u/qualified-name)] (mt/user-http-request :crowberto :get 200 "channel")))) - (testing "return all if include_inactive is true" (is (= (map #(update % :type u/qualified-name) [chn-1 (assoc chn-2 :name "Channel 2")]) (mt/user-http-request :crowberto :get 200 "channel" {:include_inactive true})))))) @@ -98,7 +93,6 @@ (get-channel :rasta 403) (is (= #{} (get-channels :rasta)))) - (mt/when-ee-evailable (with-disabled-subscriptions-permissions! (mt/with-user-in-groups [group {:name "test notification perm"} @@ -109,7 +103,6 @@ (is (= #{} (get-channels (:id user)))) (get-channel user 403)) - (testing "can see channels if they have settings permissions" (perms/grant-application-permissions! group :setting) (get-channel user 200) @@ -121,7 +114,6 @@ (is (=? {:errors {:type "Must be a namespaced channel. E.g: channel/http"}} (mt/user-http-request :crowberto :post 400 "channel" (assoc default-test-channel :type "metabase-test")))) - (is (=? {:errors {:type "Must be a namespaced channel. E.g: channel/http"}} (mt/user-http-request :crowberto :post 400 "channel" (assoc default-test-channel :type "metabase/metabase-test"))))) @@ -130,7 +122,6 @@ (is (=? {:errors {:type "nullable Must be a namespaced channel. E.g: channel/http"}} (mt/user-http-request :crowberto :put 400 (str "channel/" (:id chn-1)) (assoc chn-1 :type "metabase-test")))) - (is (=? {:errors {:type "nullable Must be a namespaced channel. E.g: channel/http"}} (mt/user-http-request :crowberto :put 400 (str "channel/" (:id chn-1)) (assoc chn-1 :type "metabase/metabase-test"))))))) @@ -151,14 +142,12 @@ (mt/user-http-request :crowberto :post 200 "channel/test" (assoc default-test-channel :details {:return-type "return-value" :return-value true}))))) - (testing "returns text error message if the channel return falsy value" (is (= {:message "Unable to connect channel" :data {:connection-result false}} (mt/user-http-request :crowberto :post 400 "channel/test" (assoc default-test-channel :details {:return-type "return-value" :return-value false}))))) - (testing "return the exception message and data if the channel throws an exception" (is (= {:message "Test error" :data {:errors {:email "Invalid email"}}} @@ -202,7 +191,6 @@ :details {:url url :auth-method "none" :auth-info {}}}))] - (testing "external-only strategy (default)" (testing "blocks localhost addresses" (is (= "URLs referring to hosts that supply internal hosting metadata are prohibited." @@ -216,7 +204,6 @@ (is (= "URLs referring to hosts that supply internal hosting metadata are prohibited." (:message (channel-test "http://169.254.1.100/api/health" 400)))))) - (testing "allow-private strategy" (mt/with-temporary-setting-values [http-channel-host-strategy :allow-private] (testing "still blocks localhost addresses" @@ -227,7 +214,6 @@ (is (= "URLs referring to hosts that supply internal hosting metadata are prohibited." (:message (channel-test "http://169.254.1.100/api/health" 400))))))) - (mt/with-temporary-setting-values [http-channel-host-strategy :allow-all] (channel.http-test/with-server [url [channel.http-test/post-200 channel.http-test/post-400]] (testing "allow-all strategy allows localhost" @@ -250,7 +236,6 @@ :topic :channel-create :user_id (mt/user->id :crowberto)} (mt/latest-audit-log-entry :channel-create)))) - (testing "PUT /api/channel/:id" (mt/user-http-request :crowberto :put 200 (str "channel/" id) (assoc default-test-channel :name "Updated Name")) (is (= {:details {:new {:name "Updated Name"} :previous {:name "Test channel"}} diff --git a/test/metabase/channel/api/email_test.clj b/test/metabase/channel/api/email_test.clj index 8e398c8bdac6..ef0f885cc866 100644 --- a/test/metabase/channel/api/email_test.clj +++ b/test/metabase/channel/api/email_test.clj @@ -127,7 +127,6 @@ (is (nil? (:email-smtp-username response))) (is (nil? (:email-smtp-password response))) (is (= 123 (:email-smtp-port response))) - (is (nil? (setting/get-value-of-type :string :email-smtp-username))) (is (nil? (setting/get-value-of-type :string :email-smtp-password))) (is (= 123 (setting/get-value-of-type :integer :email-smtp-port))))))))) diff --git a/test/metabase/channel/api/slack_test.clj b/test/metabase/channel/api/slack_test.clj index b8d85d65ca0d..a04062fa3dfb 100644 --- a/test/metabase/channel/api/slack_test.clj +++ b/test/metabase/channel/api/slack_test.clj @@ -137,7 +137,6 @@ {:type "button", :text {:type "plain_text", :text "Download the report", :emoji true}, :url "https://files.slack.com/files-pri/123/diagnostic.json"}]}]] - (testing "should post bug report to Slack with correct blocks" (with-redefs [slack/upload-file! (constantly mock-file-info) slack/post-chat-message! (constantly nil) @@ -149,7 +148,6 @@ (is (= {:success true :file-url "https://slack.com/files/123/diagnostic.json"} response)))))) - (testing "should handle anonymous reports" (with-redefs [slack/upload-file! (constantly mock-file-info) slack/post-chat-message! (constantly nil) diff --git a/test/metabase/channel/email_test.clj b/test/metabase/channel/email_test.clj index 5a4c0f7be772..b4cfb62b455c 100644 --- a/test/metabase/channel/email_test.clj +++ b/test/metabase/channel/email_test.clj @@ -251,8 +251,8 @@ (defn temp-csv [file-basename content] (prog1 (File/createTempFile file-basename ".csv") - (with-open [file (io/writer <>)] - (.write ^java.io.Writer file ^String content)))) + (with-open [file (io/writer <>)] + (.write ^java.io.Writer file ^String content)))) (defn mock-send-email! "To stub out email sending, instead returning the would-be email contents as a string" @@ -438,7 +438,6 @@ (send-email {:to ["4@metabase.com"]}))) (testing "still ok if there is no recipient" (is (some? (send-email {}))))) - (testing "with 1 small then 1 big event" (with-redefs [email/email-throttler (#'email/make-email-throttler 3)] (is (some? (send-email {:to ["1@metabase.com"]}))) @@ -448,7 +447,6 @@ Exception #"Too many attempts!.*" (send-email {:to ["4@metabase.com"]}))))))) - (testing "if an email has # of recipients greater than the limit" (testing "we skip throttle check if we haven't reached the limit" (with-redefs [email/email-throttler (#'email/make-email-throttler 3)] @@ -461,7 +459,6 @@ Exception #"Too many attempts!.*" (send-email {:to ["6@metabase.com"]})))))) - (testing "still throttle if we already at limit" (with-redefs [email/email-throttler (#'email/make-email-throttler 3)] ;; mx otu the limit @@ -471,7 +468,6 @@ Exception #"Too many attempts!.*" (send-email {:to ["4@metabase.com" "5@metabase.com" "6@metabase.com" "7@metabase.com"]}))))))) - (testing "keep retrying will eventually send the email" (with-redefs [email/email-throttler (throttle/make-throttler :email diff --git a/test/metabase/channel/impl/email_test.clj b/test/metabase/channel/impl/email_test.clj index 8627900eda73..354e2fa20187 100644 --- a/test/metabase/channel/impl/email_test.clj +++ b/test/metabase/channel/impl/email_test.clj @@ -38,12 +38,10 @@ :include_xls true} result (with-redefs [render.util/is-visualizer-dashcard? (constantly true)] (#'email.impl/assoc-attachment-booleans [matching-part-config] [visualizer-part]))] - (is (true? (-> result first :card :include_csv)) "Should include CSV attachment setting from matching part config") (is (true? (-> result first :card :include_xls)) "Should include XLS attachment setting from matching part config"))) - (testing "falls back to matching on card_id only when no perfect visualizer match is found" (let [regular-dashcard {:id 200} regular-part {:card {:id 2 :name "Regular Card"} @@ -53,7 +51,6 @@ :include_csv true :format_rows true} result (#'email.impl/assoc-attachment-booleans [matching-part-config] [regular-part])] - (is (true? (-> result first :card :include_csv)) "Should include CSV attachment setting using the fallback match") (is (true? (-> result first :card :format_rows)) @@ -87,7 +84,6 @@ (binding [urls/*dashcard-parameters* dashboard-params] (let [result (channel/render-notification :channel/email notification {:recipients recipients}) rendered-content (-> result first :message first :content)] - (testing "Dashboard parameters are bound during rendering" (is (some? result)) (is (= 1 (count result))) @@ -104,7 +100,6 @@ (is (= 1.0 (mt/metric-value system :metabase-notification/template-render {:template-type :email/handlebars-text :channel-type :channel/email})))))) - (testing "rendering a resource template also increments the counter with the correct label" (mt/with-prometheus-system! [_ system] (let [template {:details {:type :email/handlebars-resource diff --git a/test/metabase/channel/impl/http_test.clj b/test/metabase/channel/impl/http_test.clj index b445fd4cd875..3fcd181ce9bf 100644 --- a/test/metabase/channel/impl/http_test.clj +++ b/test/metabase/channel/impl/http_test.clj @@ -151,7 +151,6 @@ (can-connect? {:url (str url (:path route)) :auth-method "none" :method "get"}))] - (testing "connect successfully with 200" (is (true? (can-connect?* get-200)))) (testing "connect successfully with 302 redirect to 200" @@ -182,7 +181,6 @@ :method "get" :auth-method "header" :auth-info {:x-api-key "SECRET"}})))) - (testing "fail to connect with header auth" (is (= {:request-status 401 :request-body "Unauthorized"} @@ -244,15 +242,12 @@ (is (= {:errors {:url [(deferred-tru "value must be a valid URL.")]}} (exception-data (can-connect? {:url "not-an-url" :auth-method "none"}))))) - (testing "testing missing auth-method" (is (= {:errors {:auth-method ["missing required key"]}} (exception-data (can-connect? {:url "https://www.secret_service.xyz"}))))) - (testing "include undefined key" (is (=? {:errors {:xyz ["disallowed key"]}} (exception-data (can-connect? {:xyz "hello world"}))))) - (mt/with-temporary-setting-values [http-channel-host-strategy :allow-all] (with-server [url [get-400]] (is (= {:request-body "Bad request" @@ -260,7 +255,6 @@ (exception-data (can-connect? {:url (str url (:path get-400)) :method "get" :auth-method "none"}))))) - (with-server [url [(make-route :get "/test_http_channel_400" (fn [_] {:status 400 @@ -285,7 +279,6 @@ {:method :get :url "https://www.secret_service.xyz"}) (first @requests))))) - (testing "default method is post" (with-captured-http-requests [requests] (channel/send! {:type :channel/http @@ -296,7 +289,6 @@ {:method :post :url "https://www.secret_service.xyz"}) (first @requests))))) - (testing "preserves req headers when use auth-method=:header" (with-captured-http-requests [requests] (channel/send! {:type :channel/http @@ -311,7 +303,6 @@ :headers {:Authorization "Bearer 123" :X-Request-Id "123"}}) (first @requests))))) - (testing "preserves req query-params when use auth-method=:query-param" (with-captured-http-requests [requests] (channel/send! {:type :channel/http diff --git a/test/metabase/channel/models/channel_test.clj b/test/metabase/channel/models/channel_test.clj index aefbf6ab5969..a3ce8cbfc99d 100644 --- a/test/metabase/channel/models/channel_test.clj +++ b/test/metabase/channel/models/channel_test.clj @@ -26,7 +26,6 @@ (is (pos? (t2/update! :model/Channel id {:name "New name"}))) (is (zero? (t2/update! :model/Channel id {:active true}))) (is (t2/exists? :model/PulseChannel pc-id))) - (testing "deactivate channel" (t2/update! :model/Channel id {:active false}) (testing "will delete pulse channels" @@ -47,13 +46,11 @@ (is (some? (insert! {:details {:type "email/handlebars-text" :subject "Hello {{name}}" :body "Welcome {{name}}"}})))) - (testing "invalid template" (is (thrown? Exception (insert! {:details {:type "email/handlebars-text" :subject "Hello {{name}" :body nil}}))))) - (testing "template is a resource path" (testing "success" (is (some? (insert! {:details {:type "email/handlebars-resource" diff --git a/test/metabase/channel/params_test.clj b/test/metabase/channel/params_test.clj index 368b981a1aed..8e1d362b1483 100644 --- a/test/metabase/channel/params_test.clj +++ b/test/metabase/channel/params_test.clj @@ -10,9 +10,7 @@ "Hello ngoc@metabase.com!" "Hello {{email}}!" {:email "ngoc@metabase.com"} ;; nested access "Hello ngoc@metabase.com!" "Hello {{user.email}}!" {:user {:email "ngoc@metabase.com"}}) - (testing "throw an error if missing params" (is (thrown? Exception (channel.params/substitute-params "Hello {{email}}!" {})))) - (testing "Do not throw an error if missing params and ignore-missing? is true" (is (= "Hello !" (channel.params/substitute-params "Hello {{email}}!" {} :ignore-missing? true))))) diff --git a/test/metabase/channel/render/body_test.clj b/test/metabase/channel/render/body_test.clj index 9430e8d63612..682f8a74be0a 100644 --- a/test/metabase/channel/render/body_test.clj +++ b/test/metabase/channel/render/body_test.clj @@ -136,13 +136,11 @@ :base_type :type/Text :semantic_type nil :visibility_type :normal}]] - (testing "card contains custom column names" (is (= {:row ["Custom Last Login" "Custom Name"]} (first (#'body/prep-for-html-rendering pacific-tz card {:cols cols :rows []}))))) - (testing "card does not contain custom column names" (is (= {:row ["Last Login" "Name"]} (first (#'body/prep-for-html-rendering pacific-tz diff --git a/test/metabase/channel/render/js/color_test.clj b/test/metabase/channel/render/js/color_test.clj index 99077dba3343..9b9e74b9603d 100644 --- a/test/metabase/channel/render/js/color_test.clj +++ b/test/metabase/channel/render/js/color_test.clj @@ -92,31 +92,26 @@ (is (= red (js.color/get-background-color color-selector (formatter/->TextWrapper "" "") "test" 0)))) (testing "TextWrapper cell with original value of nil should not receive color" (is (nil? (js.color/get-background-color color-selector (formatter/->TextWrapper "" nil) "test" 0)))))))) - (deftest convert-bignumbers-by-column-test (testing "convert-bignumbers-by-column should convert BigDecimal and BigInteger values to doubles/longs" (let [convert-fn #'js.color/convert-bignumbers-by-column] (testing "empty data returns empty vector" (is (= [] (convert-fn [])))) - (testing "data with no BigDecimals or BigIntegers remains unchanged" (let [data [[1 2 "test"] [3 4 "another"]]] (is (= data (convert-fn data))))) - (testing "BigDecimal values are converted to doubles" (let [big-decimal (BigDecimal. "123.456") data [[big-decimal 2 "test"] [big-decimal 4 "another"]] result (convert-fn data)] (is (= [[123.456 2 "test"] [123.456 4 "another"]] result)) (is (every? #(instance? Double (first %)) result)))) - (testing "BigInteger values are converted to longs" (let [big-integer (BigInteger. "987654321") data [[big-integer 2 "test"] [big-integer 4 "another"]] result (convert-fn data)] (is (= [[987654321 2 "test"] [987654321 4 "another"]] result)) (is (every? #(instance? Long (first %)) result)))) - (testing "mixed BigDecimal and BigInteger conversion" (let [big-decimal (BigDecimal. "123.456") big-integer (BigInteger. "789") @@ -125,7 +120,6 @@ (is (= [[123.456 789 "test"] [123.456 789 "another"]] result)) (is (every? #(instance? Double (first %)) result)) (is (every? #(instance? Long (second %)) result)))) - (testing "values that overflow primitive ranges become nil rather than silently truncating" (let [too-big-int (.shiftLeft (BigInteger. "1") 65) ; doesn't fit in long too-big-dec (.scaleByPowerOfTen (BigDecimal. "1") 400)] ; beyond Double range diff --git a/test/metabase/channel/settings_test.clj b/test/metabase/channel/settings_test.clj index 76baa0da408f..ca87ced7441b 100644 --- a/test/metabase/channel/settings_test.clj +++ b/test/metabase/channel/settings_test.clj @@ -71,7 +71,6 @@ (deftest smtp-override-enabled (mt/with-premium-features [:cloud-custom-smtp] - (testing "cannot enable cloud-smtp without hostname set" (mt/with-temporary-setting-values [smtp-override-enabled nil email-smtp-host-override nil] diff --git a/test/metabase/channel/shared_test.clj b/test/metabase/channel/shared_test.clj index 75acd8f07dc0..3b83e6c9e468 100644 --- a/test/metabase/channel/shared_test.clj +++ b/test/metabase/channel/shared_test.clj @@ -26,7 +26,6 @@ "0 30 17 ? * 6" "Run weekly on Friday at 5:30 PM UTC" "0 0 0 ? * 1" "Run weekly on Sunday at 12 AM UTC" "0 45 14 ? * 4" "Run weekly on Wednesday at 2:45 PM UTC")) - (testing "falls back to cron->description for complex patterns" (are [cron expected] (= expected (channel.shared/friendly-cron-description cron)) "0 0 12 1-15 * ?" "Run at 12:00 pm, between day 1 and 15 of the month UTC" @@ -34,7 +33,6 @@ "0 0 12 ? * 2,4,6" "Run at 12:00 pm, only on Monday, Wednesday and Friday UTC" "0 0 12 L * ?" "Run at 12:00 pm, on the last day of the month UTC" "0 0 12 ? * 2#1" "Run at 12:00 pm, on the first Monday of the month UTC"))) - (mt/with-dynamic-fn-redefs [channel.shared/schedule-timezone (constantly "Asia/Ho_Chi_Minh")] (testing "with timezone Asia/Ho_Chi_Minh" (is (= "Run hourly Asia/Ho_Chi_Minh" (channel.shared/friendly-cron-description "0 0 * * * ?")))))) diff --git a/test/metabase/channel/slack_test.clj b/test/metabase/channel/slack_test.clj index a5e0cee0e942..fb4b6cd381f6 100644 --- a/test/metabase/channel/slack_test.clj +++ b/test/metabase/channel/slack_test.clj @@ -85,7 +85,6 @@ (deftest conversations-list-test (testing "conversations-list" (test-auth! conversations-endpoint slack/conversations-list) - (testing ":private_channel flag determines the \"types\" param sent to slack" (are [opts conversation-types] (let [request (atom nil)] @@ -102,7 +101,6 @@ {} "public_channel" {:private-channels false} "public_channel" {:private-channels true} "public_channel,private_channel")) - (testing "should be able to fetch channels and paginate" (http-fake/with-fake-routes {conversations-endpoint (comp mock-200-response mock-conversations-response-body)} (let [expected-result (map slack/channel-transform @@ -148,7 +146,6 @@ (deftest users-list-test (testing "users-list" (test-auth! users-endpoint slack/users-list) - (testing "should be able to fetch list of users and page" (http-fake/with-fake-routes {users-endpoint (comp mock-200-response mock-users-response-body)} (let [expected-result (map slack/user-transform @@ -216,7 +213,6 @@ (is (= (t2/select-fn-set :email :model/User :is_superuser true) (set (keys recipient->emails))))) (is (false? (channel.settings/slack-token-valid?)))))) - (testing "If `slack-token-valid?` is already false, no email should be sent" (mt/reset-inbox!) (try @@ -224,7 +220,6 @@ (catch Throwable e (is (= :slack/invalid-token (:error-type (ex-data e)))) (is (= {} (mt/summarize-multipart-email #"Your Slack connection stopped working."))))))) - (testing "No email is sent during token validation checks, even if `slack-token-valid?` is currently true" (mt/with-temporary-setting-values [slack-token-valid? true] (http-fake/with-fake-routes {conversations-endpoint (fn [_] (mock-200-response {:ok false, :error "account_inactive"}))} diff --git a/test/metabase/channel/template/handlebars_test.clj b/test/metabase/channel/template/handlebars_test.clj index bc165888c915..8f9768377dbf 100644 --- a/test/metabase/channel/template/handlebars_test.clj +++ b/test/metabase/channel/template/handlebars_test.clj @@ -43,7 +43,6 @@ "Hello Ngoc" "Hello {{who.name}}" {:who {:name "Ngoc"}} "Hello " "Hello {{#unless hide_name}}{{name}}{{/unless}}" {:name "Ngoc" :hide_name true} "" "" {}) - (testing "with custom reqistry" (is (= "NGOC" (handlebars/render-string custom-hbs "{{uppercase name}}" {:name "Ngoc"})))))) @@ -51,7 +50,6 @@ (testing "Render a template with a context." (with-temp-template! [tmpl-name "tmpl.hbs" "Hello {{name}}"] (is (= "Hello Ngoc" (handlebars/render tmpl-name {:name "Ngoc"}))))) - (testing "with custom req" (with-temp-template! [tmpl-name "tmpl.hbs" "Hello {{uppercase name}}"] (is (= "Hello NGOC" (handlebars/render custom-hbs tmpl-name {:name "Ngoc"})))))) @@ -61,10 +59,8 @@ (is (true? (handlebars/valid-template-path? "metabase/channel/email/password_reset.hbs"))) (is (true? (handlebars/valid-template-path? "metabase/channel/email/notification_card.hbs"))) (is (true? (handlebars/valid-template-path? "notification/channel_template/hello_world.hbs")))) - (is (false? (handlebars/valid-template-path? "foo/bar/baz.hbs"))) (is (false? (handlebars/valid-template-path? "/metabase/channel/email/foo.hbs"))) - (testing "render rejects invalid paths" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"invalid template path" (handlebars/render "foo/bar.hbs" {}))) diff --git a/test/metabase/channel/urls_test.clj b/test/metabase/channel/urls_test.clj index b7ba0f819f54..e136975e37cb 100644 --- a/test/metabase/channel/urls_test.clj +++ b/test/metabase/channel/urls_test.clj @@ -29,11 +29,9 @@ :id "acd0dfab", :type "string/contains", :sectionId "string"}])))) - (testing "If no filters are set, the base dashboard url is returned" (is (= "https://metabase.com/dashboard/1" (urls/dashboard-url 1 {})))) - (testing "Filters slugs and values are encoded properly for the URL" (is (= "https://metabase.com/dashboard/1?%26=contains%3F" (urls/dashboard-url 1 [{:value "contains?", :slug "&"}])))))) @@ -43,7 +41,6 @@ (testing "A valid dashcard URL can be generated without parameters" (is (= "https://metabase.com/dashboard/1#scrollTo=123" (urls/dashcard-url {:dashboard_id 1 :id 123})))) - (testing "A valid dashcard URL can be generated with parameters" (binding [urls/*dashcard-parameters* [{:name "State" :slug "state" @@ -59,11 +56,9 @@ :sectionId "date"}]] (is (= "https://metabase.com/dashboard/1?state=CA&state=NY&quarter=Q1-2021#scrollTo=123" (urls/dashcard-url {:dashboard_id 1 :id 123}))))) - (testing "A valid dashcard URL can be generated with tab ID" (is (= "https://metabase.com/dashboard/1?tab=5#scrollTo=123" (urls/dashcard-url {:dashboard_id 1 :id 123 :dashboard_tab_id 5})))) - (testing "A valid dashcard URL can be generated with both parameters and tab ID" (binding [urls/*dashcard-parameters* [{:name "Category" :slug "category" diff --git a/test/metabase/classloader/impl_test.clj b/test/metabase/classloader/impl_test.clj index 46e87ae0eb5c..d339cd39aae0 100644 --- a/test/metabase/classloader/impl_test.clj +++ b/test/metabase/classloader/impl_test.clj @@ -12,11 +12,9 @@ (.setContextClassLoader (Thread/currentThread) (ClassLoader/getSystemClassLoader)) (is (= false (#'classloader/has-shared-context-classloader-as-ancestor? (.getContextClassLoader (Thread/currentThread))))) - (testing "context classloader => MB shared-context-classloader" (.setContextClassLoader (Thread/currentThread) @@#'classloader/shared-context-classloader) (is (#'classloader/has-shared-context-classloader-as-ancestor? (.getContextClassLoader (Thread/currentThread))))) - (testing "context classloader => DynamicClassLoader with MB shared-context-classloader as its parent" (.setContextClassLoader (Thread/currentThread) (DynamicClassLoader. @@#'classloader/shared-context-classloader)) (is (#'classloader/has-shared-context-classloader-as-ancestor? (.getContextClassLoader (Thread/currentThread))))))) @@ -28,13 +26,11 @@ (classloader/the-classloader) (is (= @@#'classloader/shared-context-classloader (.getContextClassLoader (Thread/currentThread))))) - (testing "if current thread context classloader === the shared context classloader it should be kept as-is" (.setContextClassLoader (Thread/currentThread) @@#'classloader/shared-context-classloader) (classloader/the-classloader) (is (= @@#'classloader/shared-context-classloader (.getContextClassLoader (Thread/currentThread))))) - (testing (str "if current thread context classloader is a *descendant* the shared context classloader it should be " "kept as-is") (let [descendant-classloader (DynamicClassLoader. @@#'classloader/shared-context-classloader)] diff --git a/test/metabase/cloud_migration/api_test.clj b/test/metabase/cloud_migration/api_test.clj index e7ce80665c95..0361f2d5d6e7 100644 --- a/test/metabase/cloud_migration/api_test.clj +++ b/test/metabase/cloud_migration/api_test.clj @@ -19,7 +19,6 @@ (mt/user-http-request :rasta :post 403 "cloud-migration") (mt/user-http-request :rasta :get 403 "cloud-migration") (mt/user-http-request :rasta :put 403 "cloud-migration/cancel") - (cloud-migration-test/mock-external-calls! (mt/user-http-request :crowberto :post 200 "cloud-migration")) (mt/user-http-request :crowberto :get 200 "cloud-migration") (mt/user-http-request :crowberto :put 200 "cloud-migration/cancel"))) @@ -45,7 +44,6 @@ {:external_id 4 :upload_url "" :state :setup}]) (try (cloud-migration.settings/read-only-mode! true) - (is (= "setup" (:state (mt/user-http-request :crowberto :get 200 "cloud-migration")))) (mt/user-http-request :crowberto :put 200 "cloud-migration/cancel") (mt/user-http-request :crowberto :get 200 "cloud-migration") diff --git a/test/metabase/cmd/copy/h2_test.clj b/test/metabase/cmd/copy/h2_test.clj index 8cf20d46279d..41741c2da185 100644 --- a/test/metabase/cmd/copy/h2_test.clj +++ b/test/metabase/cmd/copy/h2_test.clj @@ -8,11 +8,9 @@ (testing "works without file: schema" (is (= (mdb.data-source/raw-connection-string->DataSource "jdbc:h2:file:/path/to/metabase.db") (copy.h2/h2-data-source "/path/to/metabase.db")))) - (testing "works with file: schema" (is (= (mdb.data-source/raw-connection-string->DataSource "jdbc:h2:file:/path/to/metabase.db") (copy.h2/h2-data-source "file:/path/to/metabase.db")))) - (testing "works with .mv.db suffix" (is (= (mdb.data-source/raw-connection-string->DataSource "jdbc:h2:file:/path/to/metabase.db") (copy.h2/h2-data-source "file:/path/to/metabase.db.mv.db"))))) diff --git a/test/metabase/cmd/dump_to_h2_test.clj b/test/metabase/cmd/dump_to_h2_test.clj index 712eb14530a0..4c99e89fc8c3 100644 --- a/test/metabase/cmd/dump_to_h2_test.clj +++ b/test/metabase/cmd/dump_to_h2_test.clj @@ -36,7 +36,6 @@ (doseq [[filename contents] file-contents] (spit filename contents)) (dump-to-h2/dump-to-h2! tmp-h2-db) - (doseq [filename (keys file-contents)] (testing (str filename " was deleted") (is (false? (.exists (io/file filename)))))))))) @@ -80,7 +79,6 @@ (dump-to-h2/dump-to-h2! h2-file-plaintext {:dump-plaintext? true}) (dump-to-h2/dump-to-h2! h2-file-enc {:dump-plaintext? false}) (dump-to-h2/dump-to-h2! h2-file-default-enc))) - (testing "decodes settings and dashboard.details" (with-open [target-conn (.getConnection (copy.h2/h2-data-source h2-file-plaintext))] (is (= "baz" (:value (first (jdbc/query {:connection target-conn} @@ -88,7 +86,6 @@ (is (= "{\"db\":\"/tmp/test.db\"}" (:details (first (jdbc/query {:connection target-conn} "select details from metabase_database where id=1;"))))))) - (testing "when flag is set to false, encrypted settings and dashboard.details are still encrypted" (with-open [target-conn (.getConnection (copy.h2/h2-data-source h2-file-enc))] (is (not (= "baz" @@ -97,7 +94,6 @@ (is (not (= "{\"db\":\"/tmp/test.db\"}" (:details (first (jdbc/query {:connection target-conn} "select details from metabase_database where id=1;")))))))) - (testing "defaults to not decrypting" (with-open [target-conn (.getConnection (copy.h2/h2-data-source h2-file-default-enc))] (is (not (= "baz" diff --git a/test/metabase/cmd/remove_encryption_test.clj b/test/metabase/cmd/remove_encryption_test.clj index 24eb18d705e9..86d6b347c61a 100644 --- a/test/metabase/cmd/remove_encryption_test.clj +++ b/test/metabase/cmd/remove_encryption_test.clj @@ -32,7 +32,6 @@ (mt/with-temp-empty-app-db [_conn :h2] (mdb/setup-db! :create-sample-content? true) (t2/insert! :model/Setting {:key "test-setting", :value "unencrypted value"}) - (is (encryption/possibly-encrypted-string? (raw-value _conn "encryption-check"))) (is (encryption/possibly-encrypted-string? (raw-value _conn "test-setting"))) (remove-encryption!) diff --git a/test/metabase/cmd/reset_password_test.clj b/test/metabase/cmd/reset_password_test.clj index 037d178d35e7..c30618c02ca3 100644 --- a/test/metabase/cmd/reset_password_test.clj +++ b/test/metabase/cmd/reset_password_test.clj @@ -9,7 +9,6 @@ (testing "set reset token throws exception on unknown email" (is (thrown? Exception (#'reset-password/set-reset-token! "some.random.email.to.reset@metabase.com")))) - (testing "reset token generated for known email in differing case" (let [email "some.valid.user.to.reset@metabase.com"] (mt/with-temp [:model/User _ {:email (u/upper-case-en email)}] diff --git a/test/metabase/cmd/rotate_encryption_key_test.clj b/test/metabase/cmd/rotate_encryption_key_test.clj index 3e88d26b3721..08ef5a45987b 100644 --- a/test/metabase/cmd/rotate_encryption_key_test.clj +++ b/test/metabase/cmd/rotate_encryption_key_test.clj @@ -89,7 +89,6 @@ :password "nopassword" :is_active true :is_superuser false}))] - (reset! user-id (u/the-id u))) (let [secret (first (t2/insert-returning-instances! :model/Secret {:name "My Secret (plaintext)" :kind "password" @@ -104,7 +103,6 @@ :value (.getBytes secret-val StandardCharsets/UTF_8) :creator_id @user-id}))] (reset! secret-id-enc (u/the-id secret)))) - (testing "rotating with the same key is a noop" (encryption-test/with-secret-key k1 (rotate-encryption-key! k1) @@ -118,11 +116,9 @@ (is (not= "encrypted with k1" (raw-value "k1crypted"))) (is (= "encrypted with k1" (t2/select-one-fn :value :model/Setting :key "k1crypted"))) (is (mt/secret-value-equals? secret-val (t2/select-one-fn :value :model/Secret :id @secret-id-enc)))))) - (testing "settings-last-updated is updated AND plaintext" (is (not= original-timestamp (raw-value "settings-last-updated"))) (is (not (encryption/possibly-encrypted-string? (raw-value "settings-last-updated"))))) - (testing "rotating with a new key is recoverable" (encryption-test/with-secret-key k1 (rotate-encryption-key! k2)) (testing "with new key" @@ -136,7 +132,6 @@ (is (not= "{\"db\":\"/tmp/test.db\"}" (t2/select-one-fn :details :model/Database :id 1))) (is (not (mt/secret-value-equals? secret-val (t2/select-one-fn :value :model/Secret :id @secret-id-unenc))))))) - (testing "full rollback when a database details looks encrypted with a different key than the current one" (encryption-test/with-secret-key k3 (let [db (first (t2/insert-returning-instances! :model/Database {:name "k3", :engine :mysql, :details {:db "/tmp/k3.db"}}))] @@ -153,7 +148,6 @@ (encryption-test/with-secret-key k3 (is (not= {:db "/tmp/k2.db"} (t2/select-one-fn :details :model/Database :name "k2"))) (is (= {:db "/tmp/k3.db"} (t2/select-one-fn :details :model/Database :name "k3"))))) - (testing "rotate-encryption-key! to nil decrypts the encrypted keys" (t2/update! :model/Database 1 {:details {:db "/tmp/test.db"}}) (t2/update! :model/Database {:name "k3"} {:details {:db "/tmp/test.db"}}) @@ -164,6 +158,5 @@ ;; should be decrypted (is (mt/secret-value-equals? secret-val (t2/select-one-fn :value :model/Secret :id @secret-id-unenc))) (is (mt/secret-value-equals? secret-val (t2/select-one-fn :value :model/Secret :id @secret-id-enc)))) - (testing "short keys fail to rotate" (is (thrown? Throwable (rotate-encryption-key! "short"))))))))))) diff --git a/test/metabase/collections/models/collection_test.clj b/test/metabase/collections/models/collection_test.clj index 476dd468d257..45bf652105f5 100644 --- a/test/metabase/collections/models/collection_test.clj +++ b/test/metabase/collections/models/collection_test.clj @@ -78,11 +78,9 @@ (is (= [{:personal_owner_id (mt/user->id :lucky) :name "Lucky Pigeon's Personal Collection" :slug "lucky_pigeon_s_personal_collection"} - {:personal_owner_id (mt/user->id :rasta) :name "Rasta Toucan's Personal Collection" :slug "rasta_toucan_s_personal_collection"} - {:personal_owner_id nil :other "No personal Id"}] (collection/personal-collections-with-ui-details [{:personal_owner_id (mt/user->id :lucky)} @@ -113,7 +111,6 @@ :model/Collection c2 {:name "My Favorite Cards"}] (is (some? c1)) (is (some? c2)) - (testing "Duplicate names should result in duplicate slugs..." (testing "Collection 1" (is (= "my_favorite_cards" @@ -134,7 +131,6 @@ (testing "entity IDs are generated" (mt/with-temp [:model/Collection collection] (is (some? (:entity_id collection))))) - (testing "entity IDs are unique" (mt/with-temp [:model/Collection c1 {:name "My Favorite Cards"} :model/Collection c2 {:name "my_favorite Cards"}] @@ -154,7 +150,6 @@ :model/Card card {:collection_id (u/the-id collection)}] (archive-collection! collection) (is (true? (t2/select-one-fn :archived :model/Card :id (u/the-id card)))))) - (testing "check that unarchiving a Collection unarchives its Cards as well" (mt/with-temp [:model/Collection collection {} :model/Card card {:collection_id (u/the-id collection)}] @@ -169,7 +164,6 @@ Exception (mt/with-temp [:model/Collection collection {:name ""}] collection)))) - (testing "check we can't change the name of a Collection to a blank string" (mt/with-temp [:model/Collection collection] (is (thrown? @@ -270,7 +264,6 @@ (testing (pr-str (cons 'location-path args)) (is (= expected (apply collection/location-path args)))))) - (testing "invalid input" (doseq [args [["1"] [nil] @@ -291,11 +284,9 @@ (testing (pr-str (list 'location-path->ids path)) (is (= expected (collection/location-path->ids path)))) - (testing (pr-str (list 'location-path->parent-id path)) (is (= (last expected) (collection/location-path->parent-id path)))))) - (testing "invalid input" (doseq [path ["/a/" nil @@ -305,7 +296,6 @@ (is (thrown? Exception (collection/location-path->ids path)))) - (testing (pr-str (list 'location-path->parent-id path)) (is (thrown? Exception @@ -320,7 +310,6 @@ (testing (pr-str (list 'children-location collection)) (is (= expected (collection/children-location collection)))))) - (testing "invalid input" (doseq [collection [{:id 1000, :location "/a/"} {:id 1000, :location nil} @@ -418,7 +407,6 @@ (is (= expected (binding [*visible-collection-ids* visible-ids] (collection/effective-location-path {:location path}))))))) - (testing "invalid input" (doseq [path [nil [10 20]]] (testing (format "path '%s'" path) @@ -448,20 +436,17 @@ clojure.lang.ExceptionInfo #"Invalid Collection location: path is invalid" (with-collection-in-location [_ "/a/"])))) - (testing "We should be able to INSERT a Collection with a *valid* location" (mt/with-temp [:model/Collection parent] (with-collection-in-location [collection (collection/location-path parent)] (is (= (collection/location-path parent) (:location collection)))))) - (testing "Make sure we can't UPDATE a Collection to give it an invalid location" (mt/with-temp [:model/Collection collection] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid Collection location: path is invalid" (t2/update! :model/Collection (u/the-id collection) {:location "/a/"}))))) - (testing "We should be able to UPDATE a Collection and give it a new, *valid* location" (mt/with-temp [:model/Collection collection-1 {} :model/Collection collection-2 {}] @@ -473,7 +458,6 @@ clojure.lang.ExceptionInfo #"Invalid Collection location: some or all ancestors do not exist" (with-collection-in-location [_ (collection/location-path (nonexistent-collection-id))])))) - (testing "Make sure we can't UPDATE a Collection to give it a non-existent ancestors" (mt/with-temp [:model/Collection collection] (is (thrown-with-msg? @@ -493,7 +477,6 @@ (t2/delete! :model/Collection :id (u/the-id a)))) (is (= 0 (t2/count :model/Collection :id [:in (map u/the-id [a b c d e f g])]))))) - (testing "parents & siblings should be untouched" ;; ...put ;; @@ -522,17 +505,14 @@ (with-current-user-perms-for-collections! [a d] (is (= ["A"] (effective-ancestors d))))) - (testing "For D: if we don't have permissions for A, we should only see C" (with-current-user-perms-for-collections! [c d] (is (= ["C"] (effective-ancestors d))))) - (testing "For D: if we have perms for all ancestors we should see them all" (with-current-user-perms-for-collections! [a c d] (is (= ["A" "C"] (effective-ancestors d))))) - (testing "For D: if we have permissions for no ancestors, we should see nothing" (with-current-user-perms-for-collections! [d] (is (= [] @@ -583,11 +563,9 @@ :location "/A/C/" :children #{{:name "G", :id true, :description nil, :location "/A/C/F/", :children #{}}}}}}} (descendants a)))) - (testing "try for one of the children, make sure we get just that subtree" (is (= #{} (descendants b)))) - (testing "try for the other child, we should get just that subtree!" (is (= #{{:name "D" :id true @@ -600,7 +578,6 @@ :location "/A/C/" :children #{{:name "G", :id true, :description nil, :location "/A/C/F/", :children #{}}}}} (descendants c)))) - (testing "try for a grandchild" (is (= #{{:name "E", :id true, :description nil, :location "/A/C/D/", :children #{}}} (descendants d)))))) @@ -658,17 +635,14 @@ (filter #(contains? (set (map :id [a b c d e f g])) (:id %))) (map :name) set))] - (testing "If we *have* perms for everything we should just see B and C." (with-current-user-perms-for-collections! [a b c d e f g] (is (= #{"B" "C"} (effective-children a))))) - (testing "make sure that `effective-children` isn't returning children or location of children! Those should get discarded." (with-current-user-perms-for-collections! [a b c d e f g] (is (= #{:name :id :description} (set (keys (first (collection/effective-children a)))))))) - (testing "If we don't have permissions for C, C's children (D and F) should be moved up one level" ;; ;; +-> B +-> B @@ -679,7 +653,6 @@ (with-current-user-perms-for-collections! [a b d e f g] (is (= #{"B" "D" "F"} (effective-children a))))) - (testing "If we also remove D, its child (F) should get moved up, for a total of 2 levels." ;; ;; +-> B +-> B @@ -690,7 +663,6 @@ (with-current-user-perms-for-collections! [a b e f g] (is (= #{"B" "E" "F"} (effective-children a))))) - (testing "If we remove C and both its children, both grandchildren should get get moved up" ;; ;; +-> B +-> B @@ -701,7 +673,6 @@ (with-current-user-perms-for-collections! [a b e g] (is (= #{"B" "E" "G"} (effective-children a))))) - (testing "Now try with one of the Children. `effective-children` for C should be D & F" ;; ;; C -+-> D -> E C -+-> D -> E @@ -710,7 +681,6 @@ (with-current-user-perms-for-collections! [b c d e f g] (is (= #{"D" "F"} (effective-children c))))) - (testing "If we remove perms for D & F their respective children should get moved up" ;; ;; C -+-> x -> E C -+-> E @@ -719,17 +689,14 @@ (with-current-user-perms-for-collections! [b c e g] (is (= #{"E" "G"} (effective-children c))))) - (testing "For the Root Collection: can we fetch its effective children?" (with-current-user-perms-for-collections! [a b c d e f g] (is (= #{"A"} (effective-children collection/root-collection))))) - (testing "For the Root Collection: if we don't have perms for A, we should get B and C as effective children" (with-current-user-perms-for-collections! [b c d e f g] (is (= #{"B" "C"} (effective-children collection/root-collection))))) - (testing "For the Root Collection: if we remove A and C we should get B, D and F" (is (= #{"B" "D" "F"} (with-current-user-perms-for-collections! [b d e f g] @@ -778,13 +745,11 @@ "/collection/G/"} (->> (collection/perms-for-archiving a) (perms-path-ids->names collections))))) - (testing (str "Now let's move down a level. To archive B, you should need permissions for B only, since B doesn't " "have any descendants and we don't need parent permissions") (is (= #{"/collection/B/"} (->> (collection/perms-for-archiving b) (perms-path-ids->names collections))))) - (testing "but for C, you should need perms for C and D, E, F, and G (descendants) but NOT A (parent)" (is (= #{"/collection/C/" "/collection/D/" @@ -793,7 +758,6 @@ "/collection/G/"} (->> (collection/perms-for-archiving c) (perms-path-ids->names collections))))) - (testing "For D you should need D and E (descendant) but NOT C (parent)" (is (= #{"/collection/D/" "/collection/E/"} @@ -806,7 +770,6 @@ Exception #"You cannot operate on the Root Collection." (collection/perms-for-archiving collection/root-collection)))) - (testing "Let's make sure we get an Exception when we try to archive the Custom Reports Collection" (mt/with-temp [:model/Collection cr-collection {}] (with-redefs [audit/default-custom-reports-collection (constantly cr-collection)] @@ -814,13 +777,11 @@ Exception #"You cannot operate on the Custom Reports Collection." (collection/perms-for-archiving cr-collection)))))) - (testing "Let's make sure we get an Exception when we try to archive a Personal Collection" (is (thrown-with-msg? Exception #"You cannot operate on a Personal Collection." (collection/perms-for-archiving (collection/user->personal-collection (mt/fetch-user :lucky)))))) - (testing "invalid input" (doseq [input [nil {} 1]] (testing (format "input = %s" (pr-str input)) @@ -861,7 +822,6 @@ "/collection/C/"} (->> (collection/perms-for-moving b c) (perms-path-ids->names collections))))) - (testing "Ok, now let's try moving something with descendants." ;; If we move C into B, we need perms for C and all its ;; descendants, and B, since it's the new parent @@ -880,7 +840,6 @@ "/collection/G/"} (->> (collection/perms-for-moving c b) (perms-path-ids->names collections))))) - (testing "Ok, now how about moving B into the Root Collection?" ;; +-> B B* [and Root*] ;; | @@ -891,7 +850,6 @@ "/collection/B/"} (->> (collection/perms-for-moving b collection/root-collection) (perms-path-ids->names collections))))) - (testing "How about moving C into the Root Collection?" ;; +-> B A -> B ;; | @@ -913,23 +871,19 @@ (is (thrown? Exception (collection/perms-for-moving collection/root-collection a)))) - (testing "You should also see an Exception if you try to move a Collection into itself or into one its descendants..." (testing "B -> B" (is (thrown? Exception (collection/perms-for-moving b b)))) - (testing "A -> B" (is (thrown? Exception (collection/perms-for-moving a b))))) - (testing "Let's make sure we get an Exception when we try to *move* a Personal Collection" (is (thrown? Exception (collection/perms-for-moving (collection/user->personal-collection (mt/fetch-user :lucky)) a))))) - (testing "invalid input" (doseq [[collection new-parent] [[{:location "/"} nil] [{:location "/"} {}] @@ -996,7 +950,6 @@ "C" {"D" {"E" {}} "F" {"G" {}}}}} (collection-locations (vals collections)))))) - (testing "Test that we can move a Collection" ;; ;; +-> B +-> B ---> E @@ -1010,7 +963,6 @@ "C" {"D" {} "F" {"G" {}}}}} (collection-locations (vals collections)))))) - (testing "Test that we can move a Collection and its descendants get moved as well" ;; ;; +-> B +-> B ---> D -> E @@ -1023,7 +975,6 @@ (is (= {"A" {"B" {"D" {"E" {}}} "C" {"F" {"G" {}}}}} (collection-locations (vals collections)))))) - (testing "Test that we can move a Collection into the Root Collection" ;; ;; +-> B +-> B @@ -1037,7 +988,6 @@ "C" {"D" {"E" {}}}} "F" {"G" {}}} (collection-locations (vals collections)))))) - (testing "Test that we can move a Collection out of the Root Collection" ;; ;; +-> B +-> B @@ -1064,7 +1014,6 @@ "C" {"D" {"E" {}} "F" {"G" {}}}}} (collection-locations (vals collections) :archived false))))) - (testing "Test that we can archive a Collection with no descendants!" ;; +-> B +-> B ;; | | @@ -1077,7 +1026,6 @@ "C" {"D" {} "F" {"G" {}}}}} (collection-locations (vals collections) :archived false))))) - (testing "Test that we can archive a Collection *with* descendants" ;; +-> B +-> B ;; | | @@ -1103,7 +1051,6 @@ "C" {"D" {"E" {}} "F" {"G" {}}}}} (collection-locations (vals collections) :archived false))))) - (testing "Test that we can unarchive a Collection *with* descendants" ;; +-> B +-> B ;; | | @@ -1128,7 +1075,6 @@ (archive-collection! e) (is (true? (t2/select-one-fn :archived model :id (u/the-id object))))))) - (testing (format "Test that archiving applies to %ss belonging to descendant Collections" (name model)) ;; object is in E, a descendant of C; archiving C should cause object to be archived (with-collection-hierarchy! [{:keys [c e], :as _collections} (when (= model :model/NativeQuerySnippet) @@ -1149,7 +1095,6 @@ (unarchive-collection! (t2/select-one :model/Collection :id (u/the-id e))) (is (= false (t2/select-one-fn :archived model :id (u/the-id object))))))) - (testing (format "Test that unarchiving applies to %ss belonging to descendant Collections" (name model)) ;; object is in E, a descendant of C; unarchiving C should cause object to be unarchived (with-collection-hierarchy! [{:keys [c e], :as _collections} (when (= model :model/NativeQuerySnippet) @@ -1189,30 +1134,25 @@ (mt/with-temp [:model/Collection collection {:name "{new}", :namespace collection-namespace}] (is (= #{} (group->perms [collection] group))))) - (perms/grant-collection-read-permissions! group root-collection) (mt/with-temp [:model/Collection collection {:name "{new}", :namespace collection-namespace}] (testing "copy read perms" (is (= #{"/collection/{new}/read/" (perms/collection-read-path root-collection)} (group->perms [collection] group)))) - (testing "revoking root collection perms shouldn't affect perms of existing children" (perms/revoke-collection-permissions! group root-collection) (is (= #{"/collection/{new}/read/"} (group->perms [collection] group)))) - (testing "granting new root collection perms shouldn't affect perms of existing children" (perms/grant-collection-readwrite-permissions! group root-collection) (is (= #{(perms/collection-readwrite-path root-collection) "/collection/{new}/read/"} (group->perms [collection] group))))) - (testing "copy readwrite perms" (mt/with-temp [:model/Collection collection {:name "{new}", :namespace collection-namespace}] (is (= #{"/collection/{new}/" (perms/collection-readwrite-path root-collection)} (group->perms [collection] group))))) - (testing (format "Perms for Root Collection in %s namespace should not affect Collections in %s namespace" (pr-str collection-namespace) (pr-str other-namespace)) (mt/with-temp [:model/Collection collection {:name "{new}", :namespace other-namespace}] @@ -1229,7 +1169,6 @@ (is (= #{"/collection/{parent}/" "/collection/{child}/"} (group->perms [parent child] group)))))) - (testing "parent has read permissions" (mt/with-temp [:model/Collection parent {:name "{parent}"}] (perms/grant-collection-read-permissions! group parent) @@ -1237,32 +1176,27 @@ (is (= #{"/collection/{parent}/read/" "/collection/{child}/read/"} (group->perms [parent child] group)))))) - (testing "parent has no permissions" (mt/with-temp [:model/Collection parent {:name "{parent}"} :model/Collection child {:name "{child}", :location (collection/children-location parent)}] (is (= #{} (group->perms [parent child] group))))) - (testing "parent given read permissions after the fact -- should not update existing children" (mt/with-temp [:model/Collection parent {:name "{parent}"} :model/Collection child {:name "{child}", :location (collection/children-location parent)}] (perms/grant-collection-read-permissions! group parent) (is (= #{"/collection/{parent}/read/"} (group->perms [parent child] group))))) - (testing "If we have Root Collection perms they shouldn't be copied for a Child" (mt/with-temp [:model/Collection parent {:name "{parent}"}] (perms/grant-collection-read-permissions! group collection/root-collection) (mt/with-temp [:model/Collection child {:name "{child}", :location (collection/children-location parent)}] (is (= #{"/collection/root/read/"} (group->perms [parent child] group))))))) - (testing (str "Make sure that when creating a new Collection as child of a Personal Collection, no group " "permissions are created") (mt/with-temp [:model/Collection child {:name "{child}", :location (lucky-collection-children-location)}] (is (not (t2/exists? :model/Permissions :object [:like (format "/collection/%d/%%" (u/the-id child))]))))) - (testing (str "Make sure that when creating a new Collection as grandchild of a Personal Collection, no group " "permissions are created") (mt/with-temp [:model/Collection child {:location (lucky-collection-children-location)} @@ -1281,7 +1215,6 @@ (is (thrown? Exception (t2/update! :model/Collection (u/the-id personal-collection) {:archived true})))))) - (testing "Make sure we're not allowed to *move* a Personal Collection" (mt/with-temp [:model/User my-cool-user {} :model/Collection some-other-collection {}] @@ -1290,14 +1223,12 @@ Exception (t2/update! :model/Collection (u/the-id personal-collection) {:location (collection/location-path some-other-collection)})))))) - (testing "Make sure we're not allowed to change the owner of a Personal Collection" (mt/with-temp [:model/User my-cool-user] (let [personal-collection (collection/user->personal-collection my-cool-user)] (is (thrown? Exception (t2/update! :model/Collection (u/the-id personal-collection) {:personal_owner_id (mt/user->id :crowberto)})))))) - (testing "We are not allowed to change the authority_level of a Personal Collection" (mt/with-temp [:model/User my-cool-user] (let [personal-collection (collection/user->personal-collection my-cool-user)] @@ -1305,7 +1236,6 @@ Exception #"You are not allowed to change the authority level of a Personal Collection." (t2/update! :model/Collection (u/the-id personal-collection) {:authority_level "official"})))))) - (testing "Does hydrating `:personal_collection_id` force creation of Personal Collections?" (mt/with-temp [:model/User temp-user] (is (malli= [:map [:personal_collection_id ms/PositiveInt]] @@ -1328,7 +1258,6 @@ (as-> (t2/select :model/Collection :id [:in id-or-ids] {:order-by [:id]}) collections (t2/hydrate collections :is_personal) (map :is_personal collections))))] - (testing "simple hydration and batched hydration should return correctly" (is (= [true true false false] (map check-is-personal [personal-coll nested-personal-coll top-level-coll nested-top-level-coll]) @@ -1376,7 +1305,6 @@ (is (= #{"/collection/root/read/" "/collection/A/read/"} (group->perms [a] group))))) - (testing (str "to a non-personal Collection, we should create perms entries that match the Root Collection's " "entries for any groups that have Root Collection perms.") ;; Personal Collection > A Personal Collection @@ -1388,7 +1316,6 @@ (is (= #{"/collection/A/read/" "/collection/B/read/"} (group->perms [a b] group)))))) - (testing "from a descendant of a Personal Collection" (testing (str "to the Root Collection, we should create perms entries that match the Root Collection's entries " "for any groups that have Root Collection perms.") @@ -1401,7 +1328,6 @@ (is (= #{"/collection/root/" "/collection/B/"} (group->perms [a b] group))))) - (testing (str "to a non-personal Collection, we should create perms entries that match the Root Collection's " "entries for any groups that have Root Collection perms.") ;; Personal Collection > A > B Personal Collection > A @@ -1413,7 +1339,6 @@ (is (= #{"/collection/B/" "/collection/C/"} (group->perms [a b c] group))))))) - (testing "Perms should apply recursively as well..." ;; Personal Collection > A > B Personal Collection ;; ===> @@ -1449,7 +1374,6 @@ (t2/update! :model/Collection (u/the-id b) {:location (collection/children-location a)}) (is (= #{} (group->perms [a b] group)))))) - (testing "from a non-Personal Collection" (testing "to a Personal Collection, we should *delete* perms entries for it" ;; Personal Collection Personal Collection > B @@ -1461,7 +1385,6 @@ (t2/update! :model/Collection (u/the-id b) {:location (lucky-collection-children-location)}) (is (= #{"/collection/A/"} (group->perms [a b] group))))) - (testing "to a descendant of a Personal Collection, we should *delete* perms entries for it" ;; Personal Collection > A Personal Collection > A > C ;; ===> @@ -1472,7 +1395,6 @@ (t2/update! :model/Collection (u/the-id c) {:location (collection/children-location a)}) (is (= #{"/collection/B/"} (group->perms [a b c] group))))))) - (testing "Deleting perms should apply recursively as well..." ;; Personal Collection > A Personal Collection > A > B > C ;; ===> @@ -1518,7 +1440,6 @@ {:location (format "/%d/" (:id parent-collection)) :name "Child Collection" :namespace child-namespace})))) - (testing (format "You should not be able to move a Collection of namespace %s into a Collection of namespace %s" (pr-str child-namespace) (pr-str parent-namespace)) (mt/with-temp [:model/Collection collection-2 {:namespace child-namespace}] @@ -1526,7 +1447,6 @@ clojure.lang.ExceptionInfo #"Collection must be in the same namespace as its parent" (collection/move-collection! collection-2 (format "/%d/" (:id parent-collection))))))) - (testing (format "You should not be able to change the namespace of a Collection from %s to %s" (pr-str parent-namespace) (pr-str child-namespace)) (is (thrown-with-msg? @@ -1551,14 +1471,12 @@ (mt/with-temp [:model/Collection {collection-id :id}] (is (= nil (collection/check-collection-namespace :model/Card collection-id))))) - (testing "Should throw exception if namespace is not allowed" (mt/with-temp [:model/Collection {collection-id :id} {:namespace "x"}] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"A Card can only go in Collections in the \"default\" or :shared-tenant-collection or :tenant-specific or :analytics namespace." (collection/check-collection-namespace :model/Card collection-id))))) - (testing "Should throw exception if Collection does not exist" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -2022,36 +1940,28 @@ (mt/with-temp [:model/Collection {remote-synced-id :id} {:name "Remote-Synced" :is_remote_synced true} :model/Collection {regular-coll-id :id} {:name "Regular Collection"} :model/Collection {other-remote-synced-id :id} {:name "Other Library" :is_remote_synced true}] - (testing "when moving from non-remote-synced to remote-synced collection" (is (true? (collection/moving-into-remote-synced? regular-coll-id remote-synced-id)) "Should return true when moving from regular to remote-synced collection")) - (testing "when moving from remote-synced collection to remote-synced collection" (is (false? (collection/moving-into-remote-synced? remote-synced-id other-remote-synced-id)) "Should return false when moving from remote-synced collection to remote-synced collection")) - (testing "when moving from remote-synced collection to non-remote-synced collection" (is (false? (collection/moving-into-remote-synced? remote-synced-id regular-coll-id)) "Should return false when moving from remote-synced collection to non-remote-synced collection")) - (testing "when moving from non-remote-synced to non-remote-synced collection" (mt/with-temp [:model/Collection {other-regular-id :id} {:name "Other Regular"}] (is (false? (collection/moving-into-remote-synced? regular-coll-id other-regular-id)) "Should return false when moving between non-remote-synced collections"))) - (testing "when old collection ID is nil" (is (true? (collection/moving-into-remote-synced? nil remote-synced-id)) "Should return false when old collection ID is nil")) - (testing "when new collection ID is nil" (is (false? (collection/moving-into-remote-synced? regular-coll-id nil)) "Should return false when new collection ID is nil")) - (testing "when both collection IDs are nil" (is (false? (collection/moving-into-remote-synced? nil nil)) "Should return false when both collection IDs are nil")) - (testing "when collection IDs don't exist" (is (false? (collection/moving-into-remote-synced? 99999 88888)) "Should return false when collection IDs don't exist"))))) @@ -2066,7 +1976,6 @@ (with-redefs [collection/non-remote-synced-dependencies (constantly #{})] (is (= model (collection/check-non-remote-synced-dependencies model)) "Should return the model when no dependencies"))))) - (testing "when model has non-remote-synced dependencies" (mt/with-temp [:model/Card {card-id :id} {:name "Test Card"} :model/Card {dep-card-id :id} {:name "Dependency Card"}] @@ -2078,7 +1987,6 @@ #"Uses content that is not remote synced." (collection/check-non-remote-synced-dependencies (t2/instance :model/Card {:id card-id}))) "Should throw exception when dependencies exist")))) - (testing "exception contains correct data" (mt/with-temp [:model/Card {card-id :id} {:name "Test Card"} :model/Card {dep-card-id :id} {:name "Dependency Card"}] @@ -2108,17 +2016,14 @@ :type nil}] ;; Move collection to parent with into-remote-synced? true (collection/move-collection! coll (format "/%d/" parent-id) true) - ;; Check that the moved collection became remote-synced type (let [moved-coll (t2/select-one :model/Collection :id coll-id)] (is (true? (:is_remote_synced moved-coll)) "Moved collection should have remote-synced type")) - ;; Check that child collections also became remote-synced type (let [moved-child (t2/select-one :model/Collection :id child-id)] (is (true? (:is_remote_synced moved-child)) "Child collection should have remote-synced type")) - (let [moved-grandchild (t2/select-one :model/Collection :id grandchild-id)] (is (true? (:is_remote_synced moved-grandchild)) "Grandchild collection should have remote-synced type"))))) @@ -2134,12 +2039,10 @@ :type nil}] ;; Move collection to parent with into-remote-synced? false (collection/move-collection! coll (format "/%d/" parent-id) false) - ;; Check that collection types remain nil (let [moved-coll (t2/select-one :model/Collection :id coll-id)] (is (false? (:is_remote_synced moved-coll)) "Moved collection should not have remote-synced type")) - (let [moved-child (t2/select-one :model/Collection :id child-id)] (is (false? (:is_remote_synced moved-child)) "Child collection should not have remote-synced type"))))) @@ -2155,12 +2058,10 @@ :is_remote_synced true}] ;; Move remote-synced collection with into-remote-synced? true (collection/move-collection! coll (format "/%d/" parent-id)) - ;; Check that collections remain remote-synced type (let [moved-coll (t2/select-one :model/Collection :id coll-id)] (is (false? (:is_remote_synced moved-coll)) "Library collection should lose remote-synced type")) - (let [moved-child (t2/select-one :model/Collection :id child-id)] (is (false? (:is_remote_synced moved-child)) "Child of remote-synced collection lose remote-synced type"))))) @@ -2180,7 +2081,6 @@ :dataset_query (mt/mbql-query nil {:source-table (str "card__" remote-synced-card-id)})}] ;; This should succeed because the dependency (remote-synced-card) is in a remote-synced collection (collection/move-collection! coll (format "/%d/" parent-id) true) - ;; Verify the collection was moved and became remote-synced type (let [moved-coll (t2/select-one :model/Collection :id coll-id)] (is (true? (:is_remote_synced moved-coll)) @@ -2211,7 +2111,6 @@ "Exception should have 400 status code") (is (contains? ex-data :non-remote-synced-models) "Exception should contain non-remote-synced models")) - ;; Verify the transaction was rolled back - collection should not be moved or changed (let [unchanged-coll (t2/select-one :model/Collection :id coll-id)] (is (false? (:is_remote_synced unchanged-coll)) @@ -2239,14 +2138,12 @@ (is (thrown? Exception (collection/move-collection! coll (format "/%d/" parent-id) true)) "Should throw exception for non-remote-synced dependencies") - ;; Verify the transaction was completely rolled back (let [unchanged-coll (t2/select-one :model/Collection :id coll-id)] (is (false? (:is_remote_synced unchanged-coll)) "Collection type should remain unchanged after transaction rollback") (is (= "/" (:location unchanged-coll)) "Collection location should remain unchanged after transaction rollback")) - (let [unchanged-child (t2/select-one :model/Collection :id child-id)] (is (false? (:is_remote_synced unchanged-child)) "Child collection type should remain unchanged after transaction rollback") @@ -2268,7 +2165,6 @@ :dataset_query (mt/mbql-query nil {:source-table (str "card__" non-remote-synced-card-id)})}] ;; This should succeed because we're not converting to remote-synced (collection/move-collection! coll (format "/%d/" parent-id)) - ;; Verify the collection was moved but did not become remote-synced type (let [moved-coll (t2/select-one :model/Collection :id coll-id)] (is (false? (:is_remote_synced moved-coll)) @@ -2281,36 +2177,28 @@ (mt/with-temp [:model/Collection {remote-synced-id :id} {:name "Remote-Synced" :is_remote_synced true} :model/Collection {regular-coll-id :id} {:name "Regular Collection"} :model/Collection {other-remote-synced-id :id} {:name "Other Library" :is_remote_synced true}] - (testing "when moving from remote-synced collection to non-remote-synced collection" (is (true? (collection/moving-from-remote-synced? remote-synced-id regular-coll-id)) "Should return true when moving from remote-synced collection to regular collection")) - (testing "when moving from remote-synced collection to remote-synced collection" (is (false? (collection/moving-from-remote-synced? remote-synced-id other-remote-synced-id)) "Should return false when moving from remote-synced collection to remote-synced collection")) - (testing "when moving from non-remote-synced to remote-synced collection" (is (false? (collection/moving-from-remote-synced? regular-coll-id remote-synced-id)) "Should return false when moving from regular to remote-synced collection")) - (testing "when moving from non-remote-synced to non-remote-synced collection" (mt/with-temp [:model/Collection {other-regular-id :id} {:name "Other Regular"}] (is (false? (collection/moving-from-remote-synced? regular-coll-id other-regular-id)) "Should return false when moving between non-remote-synced collections"))) - (testing "when old collection ID is nil" (is (false? (collection/moving-from-remote-synced? nil regular-coll-id)) "Should return false when old collection ID is nil")) - (testing "when moving from remote-synced collection to root (new collection ID is nil)" (is (true? (collection/moving-from-remote-synced? remote-synced-id nil)) "Should return true when moving from remote-synced collection to root collection")) - (testing "when both collection IDs are nil" (is (false? (collection/moving-from-remote-synced? nil nil)) "Should return false when both collection IDs are nil")) - (testing "when collection IDs don't exist" (is (false? (collection/moving-from-remote-synced? 99999 88888)) "Should return false when collection IDs don't exist"))))) @@ -2537,7 +2425,6 @@ #"Used by remote synced content." (collection/check-remote-synced-dependents (t2/instance :model/Card {:id base-card-id}))) "Should throw exception when model has remote-synced dependents") - (try (collection/check-remote-synced-dependents (t2/instance :model/Card {:id base-card-id})) (catch clojure.lang.ExceptionInfo e @@ -2594,7 +2481,6 @@ :dashboard_id regular-dashboard-id :row 0 :col 0 :size_x 4 :size_y 4}] - (testing "Returns only dashboards in remote-synced collections" (let [dependents (collection/remote-synced-dependents (t2/instance :model/Card {:id remote-synced-card-id}))] (is (contains? (set dependents) {"Dashboard" remote-synced-dashboard-id "DashboardCard" rs-dashcard-id}) @@ -2615,7 +2501,6 @@ :dashboard_id nested-dashboard-id :row 0 :col 0 :size_x 4 :size_y 4}] - (testing "Returns dashboards from nested remote-synced collections" (let [dependents (collection/remote-synced-dependents (t2/instance :model/Card {:id remote-synced-card-id}))] (is (= #{{"Collection" remote-synced-coll-id} {"Dashboard" nested-dashboard-id "DashboardCard" dashcard-id}} @@ -2634,7 +2519,6 @@ :dashboard_id archived-dashboard-id :row 0 :col 0 :size_x 4 :size_y 4}] - (testing "Does not return archived dashboards" (let [dependents (collection/remote-synced-dependents (t2/instance :model/Card {:id remote-synced-card-id}))] (is (= {"Collection" remote-synced-coll-id} (first dependents)) @@ -2656,7 +2540,6 @@ :model/DashboardCardSeries {series-id :id} {:dashboardcard_id dashcard-id :card_id remote-synced-card-id :position 0}] - (testing "Returns dashboards that reference cards through series" (let [dependents (collection/remote-synced-dependents (t2/instance :model/Card {:id remote-synced-card-id}))] (is (= #{{"Collection" remote-synced-coll-id} {"Dashboard" remote-synced-dashboard-id "DashboardCard" dashcard-id "DashboardCardSeries" series-id}} @@ -2677,7 +2560,6 @@ :dashboard_id remote-synced-dashboard-id :row 0 :col 0 :size_x 4 :size_y 4}] - (testing "Returns both card and dashboard dependencies" (let [dependents (collection/remote-synced-dependents (t2/instance :model/Card {:id source-card-id})) dependent-models (mapcat keys dependents)] @@ -2703,7 +2585,6 @@ :model/Card _ {:name "Dependent Card" :collection_id remote-synced-parent-id :dataset_query (mt/mbql-query nil {:source-table (str "card__" remote-synced-card-id)})}] - (testing "Throws exception when trying to move collection with remote-synced dependents" (let [ex (is (thrown? Exception (collection/move-collection! child-remote-synced-collection (format "/%d/" regular-parent-id))) @@ -2715,7 +2596,6 @@ "Exception should have 400 status code") (is (contains? ex-data :remote-synced-models) "Exception should contain remote-synced dependents")))) - (testing "Collection remains unchanged after failed move" (let [unchanged-coll (t2/select-one :model/Collection :id child-remote-synced-id)] (is (true? (:is_remote_synced unchanged-coll)) @@ -2743,7 +2623,6 @@ :dashboard_id remote-synced-dashboard-id :row 0 :col 0 :size_x 4 :size_y 4}] - (testing "Throws exception when trying to move collection with dashboard dependents" (let [ex (is (thrown? Exception (collection/move-collection! child-remote-synced-collection (format "/%d/" regular-parent-id))) @@ -2755,7 +2634,6 @@ "Exception should have 400 status code") (is (contains? ex-data :remote-synced-models) "Exception should contain remote-synced dependents")))) - (testing "Collection remains unchanged after failed move" (let [unchanged-coll (t2/select-one :model/Collection :id child-remote-synced-id)] (is (true? (:is_remote_synced unchanged-coll)) @@ -2777,10 +2655,8 @@ :model/Card _ {:name "Remote-Synced Card" :collection_id child-remote-synced-id :dataset_query (mt/native-query {:query "SELECT 1"})}] - (testing "Successfully moves collection when no dependents exist" (collection/move-collection! child-remote-synced-collection (format "/%d/" regular-parent-id)) - (let [moved-coll (t2/select-one :model/Collection :id child-remote-synced-id)] (is (false? (:is_remote_synced moved-coll)) "Collection type should be cleared when moved out of remote-synced collection") @@ -2856,7 +2732,6 @@ :model/Card _ {:name "Dependent Card" :collection_id remote-synced-parent-id :dataset_query (mt/mbql-query nil {:source-table (str "card__" remote-synced-card-id)})}] - (testing "Throws exception when trying to move collection with nested dependents" (let [ex (is (thrown? Exception (collection/move-collection! child-remote-synced-collection (format "/%d/" regular-parent-id))) @@ -2868,14 +2743,12 @@ "Exception should have 400 status code") (is (contains? ex-data :remote-synced-models) "Exception should contain remote-synced dependents")))) - (testing "Collection and nested collections remain unchanged after failed move" (let [unchanged-coll (t2/select-one :model/Collection :id child-remote-synced-id)] (is (true? (:is_remote_synced unchanged-coll)) "Collection type should remain remote-synced collection after failed move") (is (= (format "/%d/" remote-synced-parent-id) (:location unchanged-coll)) "Collection location should remain unchanged after failed move")) - (let [unchanged-grandchild (t2/select-one :model/Collection :id grandchild-remote-synced-id)] (is (true? (:is_remote_synced unchanged-grandchild)) "Grandchild collection type should remain remote-synced collection after failed move") @@ -2946,23 +2819,18 @@ :model/Collection {child-of-a :id} {:name "Child of A" :is_remote_synced true :location (format "/%d/" remote-synced-root-a)}] - (testing "when moving from non-remote-synced to remote-synced collection" (is (true? (collection/moving-into-remote-synced? regular-coll-id remote-synced-root-a)) "Should return true when moving from regular to remote-synced collection")) - (testing "when moving between different remote-synced root collections" (is (false? (collection/moving-into-remote-synced? remote-synced-root-a remote-synced-root-b)) "Should return false when moving between different remote-synced root collections")) - (testing "when moving from remote-synced child to different remote-synced root" (is (false? (collection/moving-into-remote-synced? child-of-a remote-synced-root-b)) "Should return false when moving from remote-synced child to different remote-synced root")) - (testing "when moving within same remote-synced hierarchy" (is (false? (collection/moving-into-remote-synced? child-of-a remote-synced-root-a)) "Should return false when moving within same remote-synced hierarchy")) - (testing "when moving from root to remote-synced collection" (is (true? (collection/moving-into-remote-synced? nil remote-synced-root-a)) "Should return true when moving from root to remote-synced collection"))))) @@ -2975,23 +2843,18 @@ :model/Collection {child-of-a :id} {:name "Child of A" :is_remote_synced true :location (format "/%d/" remote-synced-root-a)}] - (testing "when moving from remote-synced collection to non-remote-synced collection" (is (true? (collection/moving-from-remote-synced? remote-synced-root-a regular-coll-id)) "Should return true when moving from remote-synced collection to regular collection")) - (testing "when moving between different remote-synced root collections" (is (false? (collection/moving-from-remote-synced? remote-synced-root-a remote-synced-root-b)) "Should return false when moving between different remote-synced root collections")) - (testing "when moving from remote-synced child to different remote-synced root" (is (false? (collection/moving-from-remote-synced? child-of-a remote-synced-root-b)) "Should return false when moving from remote-synced child to different remote-synced root")) - (testing "when moving from remote-synced collection to root" (is (true? (collection/moving-from-remote-synced? remote-synced-root-a nil)) "Should return true when moving from remote-synced collection to root")) - (testing "when moving within same remote-synced hierarchy" (is (false? (collection/moving-from-remote-synced? child-of-a remote-synced-root-a)) "Should return false when moving within same remote-synced hierarchy"))))) @@ -3006,19 +2869,16 @@ (testing "regular collection is included" (is (some #(= (:id regular-collection) (:id %)) (t2/select :model/Collection :id (:id regular-collection))))) - (testing "trash collection would be excluded by filter" (let [hsql-clause (mi/exclude-internal-content-hsql :model/Collection) query (t2/select :model/Collection {:where [:and [:= :id (:id trash-collection)] hsql-clause]})] (is (empty? query)))) - (testing "analytics collection would be excluded by filter" (let [hsql-clause (mi/exclude-internal-content-hsql :model/Collection) query (t2/select :model/Collection {:where [:and [:= :id (:id analytics-collection)] hsql-clause]})] (is (empty? query)))) - (testing "sample collection would be excluded by filter" (let [hsql-clause (mi/exclude-internal-content-hsql :model/Collection) query (t2/select :model/Collection @@ -3118,7 +2978,6 @@ :model/Card {dep-card-id :id} {:name "Dependent Card in Parent" :collection_id remote-synced-parent-id :dataset_query (mt/mbql-query nil {:source-table (str "card__" base-card-id)})}] - (testing "Throws exception when parent has items depending on child collection items" (let [ex (is (thrown? Exception (mt/with-current-user (mt/user->id :crowberto) @@ -3131,7 +2990,6 @@ "Exception should have 400 status code") (is (= #{{"Card" dep-card-id}} (set (ex-data :remote-synced-models))) "Exception should contain remote-synced dependents")))) - (testing "Collection is NOT archived when exception is thrown" (let [child-after (t2/select-one :model/Collection :id child-id)] (is (false? (:archived child-after)) @@ -3151,11 +3009,9 @@ :model/Card _ {:name "Independent Card in Parent" :collection_id remote-synced-parent-id :dataset_query (mt/native-query {:query "SELECT 2"})}] - (testing "Successfully archives child collection when parent has no dependents on it" (mt/with-current-user (mt/user->id :crowberto) (collection/archive-collection! child-coll))) - (testing "Child collection is archived" (let [archived-child (t2/select-one :model/Collection :id child-id)] (is (true? (:archived archived-child)) @@ -3172,11 +3028,9 @@ :model/Card _ {:name "Dependent Card in Same Collection" :collection_id remote-synced-id :dataset_query (mt/mbql-query nil {:source-table (str "card__" base-card-id)})}] - (testing "Successfully archives root collection (internal dependencies don't matter)" (mt/with-current-user (mt/user->id :crowberto) (collection/archive-collection! remote-synced-coll))) - (testing "Collection is archived" (let [archived-coll (t2/select-one :model/Collection :id remote-synced-id)] (is (true? (:archived archived-coll)) @@ -3198,11 +3052,9 @@ :model/Card _ {:name "Dependent Card in Parent" :collection_id regular-parent-id :dataset_query (mt/mbql-query nil {:source-table (str "card__" base-card-id)})}] - (testing "Successfully archives regular collection even with parent dependents" (mt/with-current-user (mt/user->id :crowberto) (collection/archive-collection! regular-child-coll))) - (testing "Collection is archived" (let [archived-coll (t2/select-one :model/Collection :id regular-child-id)] (is (true? (:archived archived-coll)) @@ -3225,7 +3077,6 @@ :dashboard_id parent-dashboard-id :row 0 :col 0 :size_x 4 :size_y 4}] - (testing "Throws exception when parent dashboard depends on child collection card" (let [ex (is (thrown? Exception (mt/with-current-user (mt/user->id :crowberto) @@ -3233,7 +3084,6 @@ "Should throw exception when parent dashboard depends on child items")] (is (= "Used by remote synced content." (ex-message ex)) "Exception should have correct message"))) - (testing "Collection is NOT archived when exception is thrown" (let [child-after (t2/select-one :model/Collection :id child-id)] (is (false? (:archived child-after)) @@ -3254,11 +3104,9 @@ :collection_id remote-synced-parent-id :archived true :dataset_query (mt/mbql-query nil {:source-table (str "card__" base-card-id)})}] - (testing "Successfully archives when parent dependents are already archived" (mt/with-current-user (mt/user->id :crowberto) (collection/archive-collection! child-coll))) - (testing "Child collection is archived" (let [archived-child (t2/select-one :model/Collection :id child-id)] (is (true? (:archived archived-child)) diff --git a/test/metabase/collections/test_utils.clj b/test/metabase/collections/test_utils.clj index a82a9a20c079..e24d90309d61 100644 --- a/test/metabase/collections/test_utils.clj +++ b/test/metabase/collections/test_utils.clj @@ -29,7 +29,6 @@ (t2/update! (t2/table-name :model/Collection) :type collection/library-metrics-collection-type {:type nil}) - (try ~@body (finally diff --git a/test/metabase/collections_rest/api_test.clj b/test/metabase/collections_rest/api_test.clj index 7346a01d2aa4..3138616db938 100644 --- a/test/metabase/collections_rest/api_test.clj +++ b/test/metabase/collections_rest/api_test.clj @@ -178,7 +178,6 @@ (->> (mt/user-http-request :rasta :get 200 "collection") remove-other-collections (map :name))))) - (testing "Check that if we pass `?archived=true` we instead see archived Collections" (is (= ["Archived Collection"] (->> (mt/user-http-request :rasta :get 200 "collection" :archived :true) @@ -197,13 +196,11 @@ (testing "shouldn't show Collections of a different `:namespace` by default" (is (= ["Normal Collection"] (collection-names (mt/user-http-request :rasta :get 200 "collection"))))) - (perms/grant-collection-read-permissions! (perms/all-users-group) coins-id) (testing "By passing `:namespace` we should be able to see Collections of that `:namespace`" (testing "?namespace=currency" (is (= ["Coin Collection"] (collection-names (mt/user-http-request :rasta :get 200 "collection?namespace=currency"))))) - (testing "?namespace=stamps" (is (= [] (collection-names (mt/user-http-request :rasta :get 200 "collection?namespace=stamps"))))))))))) @@ -492,13 +489,11 @@ (testing "shouldn't show Collections of a different `:namespace` by default" (is (= [{:name "Normal Collection", :children []}] (collection-tree-view ids (mt/user-http-request :rasta :get 200 "collection/tree"))))) - (perms/grant-collection-read-permissions! (perms/all-users-group) coins-id) (testing "By passing `:namespace` we should be able to see Collections of that `:namespace`" (testing "?namespace=currency" (is (= [{:name "Coin Collection", :children []}] (collection-tree-view ids (mt/user-http-request :rasta :get 200 "collection/tree?namespace=currency"))))) - (testing "?namespace=stamps" (is (= [] (collection-tree-view ids (mt/user-http-request :rasta :get 200 "collection/tree?namespace=stamps"))))))))))) @@ -512,26 +507,21 @@ (let [ids [normal-id coins-id stamps-id]] (perms/grant-collection-read-permissions! (perms/all-users-group) coins-id) (perms/grant-collection-read-permissions! (perms/all-users-group) stamps-id) - (testing "single namespace via namespaces param" (is (= [{:name "Coin Collection", :children []}] (collection-tree-view ids (mt/user-http-request :rasta :get 200 "collection/tree?namespaces=currency"))))) - (testing "multiple namespaces via repeated namespaces param" (is (= [{:name "Coin Collection", :children []} {:name "Stamp Collection", :children []}] (collection-tree-view ids (mt/user-http-request :rasta :get 200 "collection/tree" :namespaces ["currency" "stamps"]))))) - (testing "empty string in namespaces matches nil namespace (default collections)" (is (= [{:name "Normal Collection", :children []}] (collection-tree-view ids (mt/user-http-request :rasta :get 200 "collection/tree?namespaces="))))) - (testing "combining nil namespace with other namespaces" (is (= [{:name "Coin Collection", :children []} {:name "Normal Collection", :children []}] (collection-tree-view ids (mt/user-http-request :rasta :get 200 "collection/tree" :namespaces ["currency" ""])) (collection-tree-view ids (mt/user-http-request :rasta :get 200 "collection/tree" :namespaces ["currency" nil]))))) - (testing "namespace and namespaces params are mutually exclusive" (is (= "Invalid Request." (mt/user-http-request :rasta :get 400 "collection/tree?namespace=currency&namespaces=stamps"))))))))) @@ -606,18 +596,15 @@ (mt/with-temp [:model/Collection collection {:name "Coin Collection"}] (is (=? {:name "Coin Collection"} (mt/user-http-request :rasta :get 200 (str "collection/" (u/the-id collection))))))) - (testing "check that we can see collection details using entity ID" (mt/with-temp [:model/Collection collection {:name "Coin Collection"}] (is (=? {:name "Coin Collection"} (mt/user-http-request :rasta :get 200 (str "collection/" (:entity_id collection))))))) - (testing "check that collections detail properly checks permissions" (mt/with-non-admin-groups-no-root-collection-perms (mt/with-temp [:model/Collection collection] (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (str "collection/" (u/the-id collection)))))))) - (testing "for personal collections, it should return name and slug in user's locale" (with-french-user-and-personal-collection! user collection (is (=? {:name "Collection personnelle de Taco Bell" @@ -924,13 +911,11 @@ (is (:archived (mt/user-http-request :crowberto :get 200 (str "collection/" (u/the-id collection-a))))) ;; we can't unarchive collection B without specifying a location, because it wasn't trashed directly. (is (mt/user-http-request :crowberto :put 400 (str "collection/" (u/the-id collection-b)) {:archived false})) - (mt/user-http-request :crowberto :put 200 (str "collection/" (u/the-id collection-b)) {:archived false :parent_id (u/the-id destination)}) ;; collection A is still here! (is (= #{"A"} (set-of-item-names :crowberto (collection/trash-collection-id)))) ;; collection B got moved correctly (is (= #{"B"} (set-of-item-names :crowberto destination))) - (mt/user-http-request :crowberto :put 200 (str "collection/" (u/the-id collection-a)) {:archived false :parent_id (u/the-id destination)}) (is (= #{"A" "B"} (set-of-item-names :crowberto destination)))))) @@ -1148,7 +1133,6 @@ root-items (:data (mt/user-http-request :rasta :get 200 "collection/root/items"))] (is (not (contains? (first collection-items) :can_run_adhoc_query))) (is (not (some #(contains? % :can_run_adhoc_query) root-items))))) - (testing "When include_can_run_adhoc_query=true, can_run_adhoc_query is included for cards" (let [collection-items (:data (mt/user-http-request :rasta :get 200 (str "collection/" collection-id "/items") @@ -1161,7 +1145,6 @@ (is (boolean? (:can_run_adhoc_query card-item))) (is (contains? root-card-item :can_run_adhoc_query)) (is (boolean? (:can_run_adhoc_query root-card-item))))) - (testing "can_run_adhoc_query is only added to card-like models (card, dataset, metric)" (mt/with-temp [:model/Dashboard {dashboard-id :id} {:collection_id collection-id} :model/Collection {subcoll-id :id} {:location (collection/children-location @@ -1470,11 +1453,9 @@ (testing "Can we use this endpoint to fetch our own Personal Collection?" (is (= (lucky-personal-collection) (api-get-lucky-personal-collection :lucky)))) - (testing "Can and admin use this endpoint to fetch someone else's Personal Collection?" (is (= (lucky-personal-collection) (api-get-lucky-personal-collection :crowberto)))) - (testing "Other, non-admin Users should not be allowed to fetch others' Personal Collections!" (is (= "You don't have permissions to do that." (api-get-lucky-personal-collection :rasta, :expected-status-code 403)))))) @@ -1493,7 +1474,6 @@ (testing "If we have a sub-Collection of our Personal Collection, that should show up" (is (partial= lucky-personal-subcollection-item (api-get-lucky-personal-collection-with-subcollection :lucky)))) - (testing "sub-Collections of other's Personal Collections should show up for admins as well" (is (partial= lucky-personal-subcollection-item (api-get-lucky-personal-collection-with-subcollection :crowberto)))))) @@ -2132,12 +2112,10 @@ (is (nil? (t2/select-one-fn :dashboard_id :model/Card card1-id))) (is (nil? (t2/select-one-fn :dashboard_id :model/Card card2-id))) (is (nil? (t2/select-one-fn :dashboard_id :model/Card card3-id)))) - (testing "move only card1 and card2" (mt/user-http-request :crowberto :post 200 (format "collection/%d/move-dashboard-question-candidates" coll-id) {:card_ids #{card1-id card2-id}})) - (testing "verify only specified cards were moved" (is (= dash1-id (t2/select-one-fn :dashboard_id :model/Card card1-id))) (is (= dash1-id (t2/select-one-fn :dashboard_id :model/Card card2-id))) @@ -2154,7 +2132,6 @@ ;; Initially no cards should have dashboard_id set (is (nil? (t2/select-one-fn :dashboard_id :model/Card card1-id))) (is (nil? (t2/select-one-fn :dashboard_id :model/Card card2-id))) - ;; Mock card/update-card! to fail on the second call (mt/with-dynamic-fn-redefs [card/update-card! (let [call-count (atom 0)] @@ -2165,7 +2142,6 @@ (apply (mt/dynamic-value card/update-card!) args)))] (mt/user-http-request :crowberto :post 500 (format "collection/%d/move-dashboard-question-candidates" coll-id))) - ;; Verify neither card was moved (operation rolled back) (is (nil? (t2/select-one-fn :dashboard_id :model/Card card1-id))) (is (nil? (t2/select-one-fn :dashboard_id :model/Card card2-id)))))) @@ -2507,13 +2483,11 @@ (testing "children" (is (partial= (map collection-item ["A"]) (remove-non-test-collections (api-get-root-collection-children))))))) - (testing "...and collapsing children should work for the Root Collection as well" (with-collection-hierarchy! [b d e f g] (testing "children" (is (partial= (map collection-item ["B" "D" "F"]) (remove-non-test-collections (api-get-root-collection-children))))))) - (testing "does `archived` work on Collections as well?" (with-collection-hierarchy! [a b d e f g] (mt/user-http-request :crowberto :put 200 (str "collection/" (u/the-id a)) @@ -2523,7 +2497,6 @@ (mt/user-http-request :crowberto :put 200 (str "collection/" (u/the-id a)) {:archived true}) (is (= [] (remove-non-test-collections (api-get-root-collection-children)))))) - (testing "\n?namespace= parameter" (mt/with-temp [:model/Collection {normal-id :id} {:name "Normal Collection"} :model/Collection {coins-id :id} {:name "Coin Collection", :namespace "currency"}] @@ -2536,7 +2509,6 @@ (testing "should only show Collections in the 'default' namespace by default" (is (= ["Normal Collection"] (collection-names (mt/user-http-request :rasta :get 200 "collection/root/items"))))) - (testing "By passing `:namespace` we should be able to see Collections in that `:namespace`" (testing "?namespace=currency" (is (= ["Coin Collection"] @@ -2574,20 +2546,16 @@ :entity_id (:entity_id snippet-2) :model "snippet"}] (only-test-items (:data (mt/user-http-request :rasta :get 200 "collection/root/items?namespace=snippets"))))) - (testing "\nSnippets should not come back for the default namespace" (is (= ["My Dashboard"] (only-test-item-names (:data (mt/user-http-request :rasta :get 200 "collection/root/items")))))) - (testing "\nSnippets shouldn't be paginated, because FE is not ready for it yet and default pagination behavior is bad" (is (= ["My Snippet", "My Snippet 2"] (only-test-item-names (:data (mt/user-http-request :rasta :get 200 "collection/root/items?namespace=snippets&limit=1&offset=0")))))) - (testing "\nShould be able to fetch archived Snippets" (is (= ["Archived Snippet"] (only-test-item-names (:data (mt/user-http-request :rasta :get 200 "collection/root/items?namespace=snippets&archived=true")))))) - (testing "\nShould be able to pass ?model=snippet, even though it makes no difference in this case" (is (= ["My Snippet", "My Snippet 2"] (only-test-item-names (:data (mt/user-http-request :rasta :get 200 @@ -2797,7 +2765,6 @@ (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (str "collection/" (u/the-id collection)) {:archived true}))))) - (testing "Perms checking should be recursive as well..." ;; Create Collections A > B, and grant permissions for A. You should not be allowed to archive A because you ;; would also need perms for B @@ -2843,7 +2810,6 @@ (update :location collection-test/location-path-ids->names) (update :id integer?) (update :entity_id string?)))))) - (testing "I shouldn't be allowed to move the Collection without proper perms." (testing "If I want to move A into B, I should need permissions for both A and B" (mt/with-non-admin-groups-no-root-collection-perms @@ -2853,7 +2819,6 @@ (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (str "collection/" (u/the-id collection-a)) {:parent_id (u/the-id collection-b)})))))) - (testing "Perms checking should be recursive as well..." (testing "Create A, B, and C; B is a child of A." (testing "Grant perms for A and B. Moving A into C should fail because we need perms for C" @@ -2868,7 +2833,6 @@ (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (str "collection/" (u/the-id collection-a)) {:parent_id (u/the-id collection-c)})))))) - (testing "Grant perms for A and C. Moving A into C should fail because we need perms for B." ;; A* -> B ==> C -> A -> B ;; C* @@ -2881,7 +2845,6 @@ (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (str "collection/" (u/the-id collection-a)) {:parent_id (u/the-id collection-c)})))))) - (testing "Grant perms for B and C. Moving A into C should fail because we need perms for A" ;; A -> B* ==> C -> A -> B ;; C* @@ -2944,15 +2907,12 @@ (testing "Should be able to fetch the permissions graph for the default namespace" (is (= {"Default A" "read", "Default A -> B" "read"} (nice-graph (mt/user-http-request :crowberto :get 200 "collection/graph"))))) - (testing "Should be able to fetch the permissions graph for a non-default namespace" (is (= {"Currency A" "read", "Currency A -> B" "read"} (nice-graph (mt/user-http-request :crowberto :get 200 "collection/graph?namespace=currency"))))) - (testing "have to be a superuser" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "collection/graph"))))) - (testing "PUT /api/collection/graph\n" (testing "Should be able to update the graph for the default namespace.\n" (testing "Should ignore updates to Collections outside of the namespace" @@ -2960,7 +2920,6 @@ (assoc (graph/graph) :groups {group-id {default-ab :write, currency-ab :write}}))] (is (= {"Default A" "read", "Default A -> B" "write"} (nice-graph response)))))) - (testing "Should be able to update the graph for a non-default namespace.\n" (testing "Should ignore updates to Collections outside of the namespace" (let [response (mt/user-http-request :crowberto :put 200 "collection/graph" @@ -2969,14 +2928,12 @@ :namespace :currency))] (is (= {"Currency A" "write", "Currency A -> B" "read"} (nice-graph response)))))) - (testing "Should require a `revision` parameter equal to the current graph's revision" (is (= (str "Looks like someone else edited the permissions and your data is out of date. " "Please fetch new data and try again.") (mt/user-http-request :crowberto :put 409 "collection/graph" (-> (graph/graph) (update :revision dec)))))) - (testing "Should be able to override the need for a `revision` parameter by passing `force=true`" (let [response (mt/user-http-request :crowberto :put 200 "collection/graph?force=true" (-> (graph/graph) @@ -2984,7 +2941,6 @@ (dissoc :revision)))] (is (= {"Default A" "read", "Default A -> B" "read"} (nice-graph response))))) - (testing "have to be a superuser" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 "collection/graph" @@ -3022,13 +2978,11 @@ :model/Dashboard _ {:collection_id collection-id} :model/Card _ {:collection_id collection-id :type :model}] - (testing "`can_write` is `true` when appropriate" (perms/revoke-collection-permissions! (perms/all-users-group) collection) (perms/grant-collection-readwrite-permissions! (perms/all-users-group) collection) (is (= #{[true "card"] [true "dataset"] [true "dashboard"]} (into #{} (map (juxt :can_write :model) (:data (mt/user-http-request :rasta :get 200 (str "collection/" collection-id "/items")))))))) - (testing "and `false` when appropriate" (perms/revoke-collection-permissions! (perms/all-users-group) collection) (perms/grant-collection-read-permissions! (perms/all-users-group) collection) @@ -3257,7 +3211,6 @@ (mt/with-temp [:model/Collection parent-collection {} :model/Collection child-collection {:location (collection/children-location parent-collection)} :model/Collection grandchild-collection {:location (collection/children-location child-collection)}] - (testing "Should return 403 if user has no permissions for descendants" (perms/revoke-collection-permissions! (perms/all-users-group) parent-collection) (perms/revoke-collection-permissions! (perms/all-users-group) child-collection) @@ -3266,7 +3219,6 @@ ;; No permissions for child or grandchild (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (str "collection/" (u/the-id parent-collection)) {:archived true})))) - (testing "Should return 403 if user only has read permissions for descendants" (perms/revoke-collection-permissions! (perms/all-users-group) parent-collection) (perms/revoke-collection-permissions! (perms/all-users-group) child-collection) @@ -3276,7 +3228,6 @@ (perms/grant-collection-read-permissions! (perms/all-users-group) grandchild-collection) (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (str "collection/" (u/the-id parent-collection)) {:archived true})))) - (testing "Should return 200 if user has read-write permissions for all descendants" (perms/revoke-collection-permissions! (perms/all-users-group) parent-collection) (perms/revoke-collection-permissions! (perms/all-users-group) child-collection) @@ -3302,7 +3253,6 @@ ;; archive collection C first, then collection A (mt/user-http-request :rasta :put 200 (str "/collection/" coll-c-id) {:archived true}) (mt/user-http-request :rasta :put 200 (str "/collection/" coll-a-id) {:archived true}) - ;; now we have: ;; - collection A > B > C ;; - but collections A and C appear in the Trash (because they were archived separately) diff --git a/test/metabase/comments/api_test.clj b/test/metabase/comments/api_test.clj index 50573ede076b..e7fe58256ffd 100644 --- a/test/metabase/comments/api_test.clj +++ b/test/metabase/comments/api_test.clj @@ -94,7 +94,6 @@ doc-id (:id created)))}]}]} (first (swap-vals! mt/inbox empty))))) - (testing "creates a reply to an existing comment" (let [content2 (tiptap [:p "Other comment"]) child (mt/user-http-request :crowberto :post 200 "comment/" @@ -120,7 +119,6 @@ :body [{:content (relaxed-re (str (:common_name (mt/fetch-user :crowberto)) " replied to a thread"))}]}]} (first (swap-vals! mt/inbox empty))))))) - (testing "comment in a thread should send emails to all participants of the thread" (let [_another (mt/user-http-request :lucky :post 200 "comment/" {:target_type "document" @@ -136,7 +134,6 @@ :body [{:content (relaxed-re (str (:common_name (mt/fetch-user :lucky)) " replied to a thread"))}]}]} (first (swap-vals! mt/inbox empty)))))))) - (testing "creates a comment for part of an entity" (let [part-id (-> doc :content first :attrs :_id) created (mt/user-http-request :rasta :post 200 "comment/" @@ -160,7 +157,6 @@ part-id (:id created)))}]}]} (first (swap-vals! mt/inbox empty))))))) - (testing "Comments with mentions send notification emails" (let [_created (mt/user-http-request :rasta :post 200 "comment/" {:target_type "document" @@ -189,7 +185,6 @@ (is (not (str/includes? email-body ""}]}]})))) - (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 9b133c5d4ad6..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 @@ -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"]] @@ -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) @@ -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 19652ea025d8..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 (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)))))))))) @@ -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))) @@ -1200,10 +1107,8 @@ :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 (let [second-result (mt/user-http-request :crowberto :put 200 (format "document/%s" document-id) @@ -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,7 +1130,6 @@ (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 (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] ;; Grant read-only access to read-only-col @@ -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,7 +1167,6 @@ (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 (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] ;; Grant read-only access to read-only-col @@ -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,7 +1207,6 @@ (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 (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] ;; Grant read-only access to read-only-col @@ -1319,7 +1215,6 @@ (perms/grant-collection-readwrite-permissions! group write-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") @@ -1331,7 +1226,6 @@ (is (= destination-col (:collection_id result))) ;; 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") @@ -1341,7 +1235,6 @@ {:collection_id destination-col}) ;; 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") @@ -1358,7 +1251,6 @@ (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 (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] ;; Grant read-only access to read-only-col @@ -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,7 +1292,6 @@ (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 (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] ;; Grant read-only access to read-only-col @@ -1447,20 +1336,16 @@ :put 200 (format "document/%s" doc-id) {:archived true})] (is (true? (:archived result))) - ;; 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 (is (false? (:archived (t2/select-one :model/Document :id doc-id))))))))) @@ -1481,34 +1366,26 @@ :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 (is (true? (:archived (t2/select-one :model/Document :id doc-id)))) - ;; 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 (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 (is (false? (:archived (t2/select-one :model/Document :id doc-id)))) - ;; 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 (is (false? (:archived (t2/select-one :model/Card :id other-card-id)))))))) @@ -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 (perms/grant-collection-read-permissions! group read-only-col) ;; 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 (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,21 +1442,17 @@ :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 (is (true? (:archived (t2/select-one :model/Collection :id coll-id)))) - ;; 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) (is (true? (:archived (t2/select-one :model/Card :id card1-id)))) (is (false? (:archived_directly (t2/select-one :model/Card :id card1-id)))) @@ -1592,19 +1460,15 @@ (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 (is (false? (:archived (t2/select-one :model/Collection :id coll-id)))) - ;; 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 (is (false? (:archived (t2/select-one :model/Card :id card1-id)))) (is (false? (:archived (t2/select-one :model/Card :id card2-id)))) @@ -1620,44 +1484,37 @@ :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 (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 (mt/user-http-request :crowberto @@ -1666,12 +1523,10 @@ (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived true}) - ;; 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 (let [doc (t2/select-one :model/Document :id doc-id) card (t2/select-one :model/Card :id card-id)] @@ -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 (mt/user-http-request :crowberto :get 200 (format "document/%s" active-doc-id)) - ;; 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 (is (some #(= :event/document-delete (:topic %)) @events)))))) - (testing "unarchiving document publishes update event" (mt/with-model-cleanup [:model/Document] (let [events (atom [])] @@ -1735,7 +1583,6 @@ (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived false}) - ;; 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,7 +1594,6 @@ :model/Card {card-id :id} {:name "Associated Card" :document_id doc-id :dataset_query (mt/mbql-query venues)}] - ;; Simulate a failure during card archiving (testing "failure during card archiving rolls back document archiving" (with-redefs [t2/update! (fn [model id updates] @@ -1757,7 +1603,6 @@ (mt/user-http-request :crowberto :put 500 (format "document/%s" doc-id) {:archived true}) - ;; 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 (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 (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 (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,7 +1658,6 @@ :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" @@ -1830,7 +1668,6 @@ :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")}] @@ -1840,7 +1677,6 @@ (is (true? (:archived result))) ;; 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 (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,7 +1708,6 @@ :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 (is (some? (t2/select-one :model/Document :id doc-id))))))) @@ -1891,22 +1724,17 @@ :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 (perms/grant-collection-read-permissions! group read-only-col) ;; 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 (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 (is (some? (t2/select-one :model/Document :id read-only-doc-id))))))))) @@ -1930,17 +1758,13 @@ :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 (is (nil? (t2/select-one :model/Document :id doc-id))) - ;; 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 (is (some? (t2/select-one :model/Card :id other-card-id))))))) @@ -1957,7 +1781,6 @@ (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 (is (some #(= :event/document-delete (:topic %)) @events)) (let [delete-event (first (filter #(= :event/document-delete (:topic %)) @events))] @@ -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 (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 (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,13 +2388,11 @@ :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 (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 21b32b29aa2f..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,7 +148,6 @@ ;; 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"} @@ -161,7 +156,6 @@ :archived false}] ;; Add document to recent views (activity-feed/update-users-recent-views! (mt/user->id :rasta) :model/Document doc-id :view) - ;; Call the API (let [response (mt/user-http-request :rasta :get 200 "activity/recents" :context [:views])] (is (some #(and (= (:model %) "document") 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 62e2152c6dc1..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 @@ -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 703892734159..88fdda39f0e4 100644 --- a/test/metabase/driver/sql/parameters/substitute_test.clj +++ b/test/metabase/driver/sql/parameters/substitute_test.clj @@ -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 445d9f33f99a..bdc16fe05d11 100644 --- a/test/metabase/driver/sql_jdbc/connection_test.clj +++ b/test/metabase/driver/sql_jdbc/connection_test.clj @@ -120,29 +120,24 @@ write-cache-key [db-id :write-data]] ;; 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 (sql-jdbc.conn/invalidate-pool-for-db! database))))))))))) @@ -182,7 +177,6 @@ (let [db-id (u/the-id database)] ;; 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,19 +185,16 @@ (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 (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 (sql-jdbc.conn/invalidate-pool-for-db! database))))))))) @@ -225,11 +216,9 @@ (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))) @@ -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 1e26ec3915a4..7dfa0622ac7c 100644 --- a/test/metabase/driver/sql_jdbc/execute_test.clj +++ b/test/metabase/driver/sql_jdbc/execute_test.clj @@ -132,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] @@ -157,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 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 06d9b16d2354..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" @@ -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 56fc14c438f1..41c67c8394cb 100644 --- a/test/metabase/driver/sql_jdbc_test.clj +++ b/test/metabase/driver/sql_jdbc_test.clj @@ -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 96579b655726..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,10 +243,8 @@ " }" "]"]) 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? (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 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 3620567b460a..3feaa1205b0b 100644 --- a/test/metabase/embedding_rest/api/embed_test.clj +++ b/test/metabase/embedding_rest/api/embed_test.clj @@ -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)] @@ -1915,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 208ff4435845..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)) 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/fk_details_test.cljc b/test/metabase/lib/drill_thru/fk_details_test.cljc index 6ef84d69a09a..017212d4bfd4 100644 --- a/test/metabase/lib/drill_thru/fk_details_test.cljc +++ b/test/metabase/lib/drill_thru/fk_details_test.cljc @@ -161,7 +161,6 @@ {: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. {:id (meta/id :orders :user-id) :semantic-type :type/FK @@ -191,7 +190,6 @@ ;; 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/pk_test.cljc b/test/metabase/lib/drill_thru/pk_test.cljc index 9d6f5b1d72b2..5611ed932c3a 100644 --- a/test/metabase/lib/drill_thru/pk_test.cljc +++ b/test/metabase/lib/drill_thru/pk_test.cljc @@ -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/zoom_in_bins_test.cljc b/test/metabase/lib/drill_thru/zoom_in_bins_test.cljc index 8608215284c1..8beb8e28b5ff 100644 --- a/test/metabase/lib/drill_thru/zoom_in_bins_test.cljc +++ b/test/metabase/lib/drill_thru/zoom_in_bins_test.cljc @@ -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 064baed31dab..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 @@ -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/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_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 fcccff8cd6bd..796480dd6179 100644 --- a/test/metabase/lib/js_test.cljs +++ b/test/metabase/lib/js_test.cljs @@ -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?] @@ -411,7 +408,6 @@ ;; 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 @@ -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/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/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/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_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/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 0f7357f6b287..37b1d3960d4c 100644 --- a/test/metabase/mcp/api_test.clj +++ b/test/metabase/mcp/api_test.clj @@ -379,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!) @@ -392,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!) @@ -421,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") @@ -486,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"])] @@ -524,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" @@ -816,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))] @@ -825,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/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 2d7760e4309a..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" @@ -126,7 +123,6 @@ :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 4f2e9214d424..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,7 +79,6 @@ :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 [_] @@ -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"}] @@ -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! (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] @@ -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)] 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 747edacaf91b..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)) @@ -37,7 +36,6 @@ (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 c4708f6791c4..6a82eae27b59 100644 --- a/test/metabase/metabot/api/metabot_test.clj +++ b/test/metabase/metabot/api/metabot_test.clj @@ -129,7 +129,6 @@ 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 (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,12 +380,10 @@ (mt/user-http-request :crowberto :put 200 (format "metabot/metabot/%d" metabot-id) {:collection_id collection-id-2}) - ;; 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 (t2/delete! :model/MetabotPrompt :metabot_id metabot-id) @@ -414,7 +393,6 @@ :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) (with-redefs [metabot.suggested-prompts/generate-sample-prompts @@ -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 756b4c6ca98a..1f9a32914ebf 100644 --- a/test/metabase/metabot/api_test.clj +++ b/test/metabase/metabot/api_test.clj @@ -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 3090bb6ae43a..d4d342739f4d 100644 --- a/test/metabase/metabot/self_test.clj +++ b/test/metabase/metabot/self_test.clj @@ -158,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"} @@ -168,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" @@ -193,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"} @@ -210,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"} @@ -224,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" @@ -241,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"} @@ -254,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"} @@ -265,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"} @@ -451,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"}})] @@ -459,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}]} @@ -514,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"}] @@ -617,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] @@ -638,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] @@ -655,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"}]))] @@ -667,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 @@ -680,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 @@ -700,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 @@ -738,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] @@ -756,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})))] @@ -770,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 @@ -783,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 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/suggested_prompts_generator_test.clj b/test/metabase/metabot/task/suggested_prompts_generator_test.clj index 9afa87cfbb01..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,21 +58,18 @@ :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 (t2/update! :model/Metabot (:id original-metabot) {:use_verified_content false})))))))) 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 d5e337091728..9a251932158b 100644 --- a/test/metabase/metabot/tools/resources_test.clj +++ b/test/metabase/metabot/tools/resources_test.clj @@ -20,7 +20,6 @@ (#'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"))) @@ -28,7 +27,6 @@ (#'read-resource/parse-uri "metabase://model/456"))) (is (= {:segments ["question" "456"] :query-params nil} (#'read-resource/parse-uri "metabase://question/456")))) - (testing "parses entity sub-resources" (is (= {:segments ["table" "123" "fields"] :query-params nil} (#'read-resource/parse-uri "metabase://table/123/fields"))) @@ -36,33 +34,26 @@ (#'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 empty path" (is (thrown? Exception (#'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 '/'). @@ -92,7 +83,6 @@ ["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"]] @@ -100,42 +90,35 @@ ["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"]]]) @@ -211,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 @@ -238,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"]}))))))) @@ -260,11 +238,9 @@ (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"]})))))))) @@ -461,7 +437,6 @@ :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 @@ -470,7 +445,6 @@ "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)] @@ -510,7 +484,6 @@ (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")) @@ -627,7 +600,6 @@ (is (str/includes? formatted "Table details here")) (is (str/includes? formatted "")) (is (str/includes? formatted "")))) - (testing "formats resources with errors" (let [resources [{:uri "metabase://table/123" :error "Table not found"}] @@ -762,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 2a907adb30fc..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)] @@ -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/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 c67c23966341..1d5f79b153ab 100644 --- a/test/metabase/notification/payload/impl/card_test.clj +++ b/test/metabase/notification/payload/impl/card_test.clj @@ -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 c7faf6aa4539..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 @@ -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/dashboard_test.clj b/test/metabase/parameters/dashboard_test.clj index a26fe4ede16b..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")))))) @@ -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,7 +277,6 @@ 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 (mt/with-column-remappings [reviews.product_id products.title @@ -293,7 +287,6 @@ 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 (binding [api/*current-user-id* (mt/user->id :rasta)] @@ -302,7 +295,6 @@ 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 516e6990ca66..0c72529e8bdb 100644 --- a/test/metabase/parameters/shared_test.cljc +++ b/test/metabase/parameters/shared_test.cljc @@ -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/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 2c1f5cbfe11a..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)))))))))) @@ -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/send_test.clj b/test/metabase/pulse/send_test.clj index 95f393d7a38d..423464906bee 100644 --- a/test/metabase/pulse/send_test.clj +++ b/test/metabase/pulse/send_test.clj @@ -274,7 +274,6 @@ (testing "attached-results-text should return nil since it's a slack message" (is (= [nil] (pulse.test-util/output @#'body/attached-results-text))))))}} - "11 rows in the results no longer causes a CSV attachment per issue #36441." {:card (pulse.test-util/checkins-query-card {:aggregation nil, :limit 11}) @@ -402,7 +401,6 @@ (is (=? {:channel "#general" :blocks (default-slack-blocks card-id true)} message)))}} - "with no data" {:card (pulse.test-util/checkins-query-card {:filter [:> $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 2aa5402c934a..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)) @@ -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))) @@ -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", @@ -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))))) @@ -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_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_test.clj b/test/metabase/query_processor/middleware/cache_test.clj index e9a546db8a50..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] 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 fac9d4f8fcb9..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,7 +128,6 @@ (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: (is (thrown-with-msg? Exception #"Nested queries are disabled" 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/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 240537900642..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 @@ -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/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 d0d7c95d8d3c..4aef00bfa8cc 100644 --- a/test/metabase/query_processor/streaming_test.clj +++ b/test/metabase/query_processor/streaming_test.clj @@ -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") @@ -767,7 +766,6 @@ ;; 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 09039001de09..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,14 +651,12 @@ ["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] @@ -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,7 +802,6 @@ :filter [:and [:not [:> $id 32]] [:contains $venue_name "BBQ"]]}))))) - (testing :time-interval (is (= [1000] (mt/first-row @@ -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/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 a56daf1c4c04..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 (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 (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 (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 10f5bc8721ff..ba00c35df4d7 100644 --- a/test/metabase/revisions/events_test.clj +++ b/test/metabase/revisions/events_test.clj @@ -132,7 +132,6 @@ (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 (is (empty? (set/intersection #{:public_uuid :made_public_by_id} 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 d32f9168feaa..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. @@ -309,7 +306,6 @@ ;; 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))))))))))) @@ -335,7 +331,6 @@ ;; 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,7 +466,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 (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" :position 0 @@ -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,12 +490,10 @@ (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 (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" :position 0 @@ -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,12 +514,10 @@ (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 (create-dashboard-revision! dashboard-id true) - ;; 1. add 2 tabs (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" :position 0 @@ -539,12 +526,10 @@ :position 1 :dashboard_id dashboard-id}])] (create-dashboard-revision! dashboard-id false) - ;; 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 (is (=? [{:id tab-2-id :name "Tab 2" :position 0}] (t2/select :model/DashboardTab :dashboard_id dashboard-id {:order-by [[:position :asc]]}))) @@ -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,7 +604,6 @@ :size_x 4 :size_y 4})) (create-dashboard-revision! dashboard-id false) - ;; revert (revert-to-previous-revision! :model/Dashboard dashboard-id 2) (testing "tab 1 should have 2 cards" @@ -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 397ddfed7cf9..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) @@ -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 aec331fbc007..aba8d07ea165 100644 --- a/test/metabase/search/appdb/index_test.clj +++ b/test/metabase/search/appdb/index_test.clj @@ -82,7 +82,6 @@ (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. ;(t2/delete! :model/Card :name "Protected Avenue") #_(is (= 0 #_1 (count (search.index/search "Projected Revenue")))) @@ -109,14 +108,12 @@ (testing "It does not match partial words" ;; 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 (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"))) @@ -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 3737217794e6..8f8510df7ebf 100644 --- a/test/metabase/search/appdb/scoring_test.clj +++ b/test/metabase/search/appdb/scoring_test.clj @@ -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 966f504954ec..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] @@ -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/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/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 8bd0d5ece75a..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))))) 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 c51fb2cca361..3e207447bb2b 100644 --- a/test/metabase/sso/integrations/slack_connect_test.clj +++ b/test/metabase/sso/integrations/slack_connect_test.clj @@ -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 e5254c73203d..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))))) @@ -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/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 2ee979df275b..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" 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/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 e21c3cd072d9..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] @@ -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 5a9a1dbc5020..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} 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 bf95667b3a82..a53fd6823e2b 100644 --- a/test/metabase/task_test.clj +++ b/test/metabase/task_test.clj @@ -148,7 +148,6 @@ (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 (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"}) 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/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/util.clj b/test/metabase/test/util.clj index a97b4877e7a4..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 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 0f501894d17e..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" diff --git a/test/metabase/transforms/jobs_test.clj b/test/metabase/transforms/jobs_test.clj index f297572207e1..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 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 69ea296898ce..7d6e15eefa39 100644 --- a/test/metabase/transforms/util_test.clj +++ b/test/metabase/transforms/util_test.clj @@ -30,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))) @@ -97,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)} @@ -108,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) @@ -127,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)) @@ -167,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"}] @@ -185,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)] @@ -201,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"}] @@ -238,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"}] @@ -273,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}]}}] @@ -291,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}}}] @@ -302,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}]}}] @@ -313,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} @@ -404,7 +381,6 @@ (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}] @@ -561,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 6448ba95057e..8b0ee1a64632 100644 --- a/test/metabase/upload/impl_test.clj +++ b/test/metabase/upload/impl_test.clj @@ -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) @@ -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 @@ -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 847acc12d114..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" @@ -518,7 +514,6 @@ (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)))))) - (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}] 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 0f8da2599bf1..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))))))) @@ -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 @@ -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 ef09808f2f97..688141f7f7f3 100644 --- a/test/metabase/warehouses_rest/api_test.clj +++ b/test/metabase/warehouses_rest/api_test.clj @@ -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} @@ -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)) @@ -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 @@ -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/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 From 8c2c8fedb8b4e6a02d9697e2c66ba173c0c50903 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Wed, 27 May 2026 10:41:33 +0300 Subject: [PATCH 63/63] =?UTF-8?q?=F0=9F=A4=96=20backported=20"Fix=20NullPo?= =?UTF-8?q?interException=20in=20render-minibar=20with=20missing=20range?= =?UTF-8?q?=20stats"=20(#74708)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix NullPointerException in render-minibar with missing range stats (#74572) * Fix NullPointerException in render-minibar with missing range stats render-minibar assumed the column fingerprint always supplied min and max values. When a minibar-enabled column lacks :type/Number fingerprint stats (e.g. an unsynced or computed column), min and max are nil and (< min 0) throws a NullPointerException that crashes the entire Slack/email notification delivery silently. Guard against nil min/max and fall back to rendering the plain formatted value, consistent with the existing non-numeric fallback. * Isolate per-card render failures in dashboard subscriptions A single card that fails to render (e.g. the minibar NPE in #74007, or any future render error) could take down an entire dashboard subscription: the per-card try/catch in render-pulse-card-body swaps in a :render-error placeholder, but render methods build the body as lazy hiccup, so an exception thrown while building it escapes that catch and only fires later when the channel realizes the hiccup (email: hiccup/html; Slack: PNG rendering) — outside any per-card boundary, aborting the whole notification. Add explicit per-part isolation at the assembly boundary, where each part is already rendered and realized one at a time: - email :notification/dashboard reduce wraps each part's render+html - slack :notification/dashboard mapcat wraps each part->sections! On failure the part degrades to the existing error placeholder (new channel.render/error-rendered-part for email; an equivalent section block for Slack) and the remaining cards still deliver. This catches any part failure, lazy or not, and leaves the happy path unchanged. * Simplify min/max nil check in render-minibar Co-authored-by: Ngoc Khuat --- src/metabase/channel/impl/email.clj | 24 +++++++++++----- src/metabase/channel/impl/slack.clj | 13 ++++++++- src/metabase/channel/render/card.clj | 7 +++++ src/metabase/channel/render/core.clj | 1 + src/metabase/channel/render/table.clj | 2 +- test/metabase/channel/impl/email_test.clj | 25 +++++++++++++++++ test/metabase/channel/impl/slack_test.clj | 26 +++++++++++++++++ test/metabase/channel/render/table_test.clj | 31 +++++++++++++++++++++ 8 files changed, 120 insertions(+), 9 deletions(-) diff --git a/src/metabase/channel/impl/email.clj b/src/metabase/channel/impl/email.clj index d428dd268802..c53613fb3d02 100644 --- a/src/metabase/channel/impl/email.clj +++ b/src/metabase/channel/impl/email.clj @@ -270,13 +270,23 @@ result-attachments html-contents] (reduce (fn [[merged-attachments result-attachments html-contents] part] - (let [{:keys [attachments content]} (render-part timezone part {:channel.render/include-title? true - :channel.render/disable-links? (boolean (:disable_links dashboard_subscription))}) - result-attachment (email.result-attachment/result-attachment part creator_id)] - [(merge merged-attachments attachments) - (into result-attachments result-attachment) - (when-not attachment_only - (conj html-contents (html content)))])) + ;; Isolate each part: realizing one part's Hiccup (here, via `html`) must not + ;; abort the whole subscription. On failure, substitute the error placeholder so + ;; the remaining cards still deliver (#74007). + (try + (let [{:keys [attachments content]} (render-part timezone part {:channel.render/include-title? true + :channel.render/disable-links? (boolean (:disable_links dashboard_subscription))}) + result-attachment (email.result-attachment/result-attachment part creator_id)] + [(merge merged-attachments attachments) + (into result-attachments result-attachment) + (when-not attachment_only + (conj html-contents (html content)))]) + (catch Throwable e + (log/error e "Error rendering dashboard subscription part; substituting error placeholder") + [merged-attachments + result-attachments + (when-not attachment_only + (conj html-contents (html (:content (channel.render/error-rendered-part)))))]))) [{} [] []] (assoc-attachment-booleans (:dashboard_subscription_dashcards dashboard_subscription) dashboard_parts)) icon-attachment (make-message-attachment (first (icon-bundle :dashboard))) diff --git a/src/metabase/channel/impl/slack.clj b/src/metabase/channel/impl/slack.clj index 01dd8c847a48..757b140e2baa 100644 --- a/src/metabase/channel/impl/slack.clj +++ b/src/metabase/channel/impl/slack.clj @@ -11,6 +11,8 @@ [metabase.parameters.shared :as shared.params] [metabase.premium-features.core :as premium-features] [metabase.system.core :as system] + [metabase.util.i18n :refer [tru]] + [metabase.util.log :as log] [metabase.util.malli :as mu] [metabase.util.markdown :as markdown])) @@ -237,7 +239,16 @@ top-level-params (impl.util/remove-inline-parameters all-params (:dashboard_parts payload)) dashboard (:dashboard payload) blocks (->> [(slack-dashboard-header dashboard (:common_name creator) all-params top-level-params) - (mapcat (partial part->sections! all-params) (:dashboard_parts payload))] + ;; Isolate each part: rendering one part (e.g. realizing its Hiccup into a PNG) + ;; must not abort the whole subscription. On failure, substitute an error + ;; placeholder block so the remaining cards still deliver (#74007). + (mapcat (fn [part] + (try + (part->sections! all-params part) + (catch Throwable e + (log/error e "Error rendering dashboard subscription part for Slack; substituting error placeholder") + [(text->markdown-section (str (tru "An error occurred while displaying this card.")))]))) + (:dashboard_parts payload))] flatten (remove nil?))] (for [channel-id (map notification-recipient->channel recipients)] diff --git a/src/metabase/channel/render/card.clj b/src/metabase/channel/render/card.clj index bb94d4c4a9e1..774ac096f5f8 100644 --- a/src/metabase/channel/render/card.clj +++ b/src/metabase/channel/render/card.clj @@ -181,6 +181,13 @@ (log/error e "Pulse card render error") (body/render :render-error nil nil nil nil nil))))))) +(mu/defn error-rendered-part :- ::body/RenderedPartCard + "The placeholder rendered-part shown when an individual card/part of a notification fails to + render. Channels substitute this for a failed part so one failure degrades to an error box + instead of breaking the whole alert/dashboard subscription." + [] + (body/render :render-error nil nil nil nil nil)) + (mu/defn render-pulse-card :- ::body/RenderedPartCard "Render a single `card` for a `Pulse` to Hiccup HTML. `result` is the QP results. Returns a map with keys diff --git a/src/metabase/channel/render/core.clj b/src/metabase/channel/render/core.clj index ddd51977f191..aeed80e2b1e7 100644 --- a/src/metabase/channel/render/core.clj +++ b/src/metabase/channel/render/core.clj @@ -26,6 +26,7 @@ [render.card detect-pulse-chart-type defaulted-timezone + error-rendered-part render-pulse-card render-pulse-card-for-display render-pulse-section diff --git a/src/metabase/channel/render/table.clj b/src/metabase/channel/render/table.clj index 2a4d81d157f3..597cf485317f 100644 --- a/src/metabase/channel/render/table.clj +++ b/src/metabase/channel/render/table.clj @@ -124,7 +124,7 @@ - Hiccup data structure for rendering HTML, or - Formatted value alone if not a valid numeric value" [val {:keys [min max]} col-styles] - (if-let [num (:num-value val)] ;; Assumes NumericWrapper + (if-let [num (and min max (:num-value val))] ;; Assumes NumericWrapper (let [is-neg? (< num 0) has-neg? (< min 0) normalized-max (clojure.core/max (abs min) (abs max)) diff --git a/test/metabase/channel/impl/email_test.clj b/test/metabase/channel/impl/email_test.clj index 354e2fa20187..b5dfea3ae7e5 100644 --- a/test/metabase/channel/impl/email_test.clj +++ b/test/metabase/channel/impl/email_test.clj @@ -90,6 +90,31 @@ (is (string? rendered-content)) (is (str/includes? rendered-content "http://example.com/dashboard/42?state=CA&state=NY&state=NJ#scrollTo=456"))))))))) +(deftest dashboard-subscription-part-error-isolation-test + (testing "One card failing to render does not break the whole dashboard subscription; the failed + card degrades to an error placeholder and the remaining cards still render (#74007)" + (let [notification {:payload_type :notification/dashboard + :payload {:dashboard {:id 1 :name "Test Dashboard"} + :parameters [] + :dashboard_parts [{:type :card :card {:id 1 :name "Good Card"} :dashcard {:id 10 :dashboard_id 1}} + {:type :card :card {:id 2 :name "Bad Card"} :dashcard {:id 20 :dashboard_id 1}}] + :dashboard_subscription {:id 9 :dashboard_subscription_dashcards []}}} + recipients [{:type :notification-recipient/user :user {:email "test@example.com"}}]] + (mt/with-temporary-setting-values [site-url "http://example.com"] + ;; The bad card returns lazy Hiccup that throws only when realized by `html` - the exact failure + ;; mode where a render error escapes the per-card try/catch and would otherwise abort delivery. + (mt/with-dynamic-fn-redefs [email.impl/render-part (fn [_timezone part _options] + (if (= 2 (-> part :card :id)) + {:content [:div (lazy-seq (throw (ex-info "boom while realizing" {})))]} + {:content [:div "GOOD-CARD-BODY"]}))] + (let [result (channel/render-notification :channel/email notification {:recipients recipients}) + content (-> result first :message first :content)] + (is (string? content)) + (testing "the healthy card still renders" + (is (str/includes? content "GOOD-CARD-BODY"))) + (testing "the failed card degrades to the error placeholder" + (is (str/includes? content "An error occurred while displaying this card."))))))))) + (deftest render-body-prometheus-metric-test (testing "rendering a user-provided template increments the template-render counter" (mt/with-prometheus-system! [_ system] diff --git a/test/metabase/channel/impl/slack_test.clj b/test/metabase/channel/impl/slack_test.clj index 1c6b45d57951..fabc03b2a616 100644 --- a/test/metabase/channel/impl/slack_test.clj +++ b/test/metabase/channel/impl/slack_test.clj @@ -1,5 +1,6 @@ (ns metabase.channel.impl.slack-test (:require + [clojure.string :as str] [clojure.test :refer :all] [metabase.channel.core :as channel] [metabase.channel.impl.slack :as channel.slack] @@ -161,3 +162,28 @@ (is (= "section" (:type card-section))) (is (= "" (-> card-section :text :text)))))))) + +(deftest dashboard-subscription-part-error-isolation-test + (testing "One card failing to render does not break the whole Slack dashboard subscription; the + failed card degrades to an error placeholder block and the rest still render (#74007)" + (let [orig @#'channel.slack/part->sections! + notification {:payload_type :notification/dashboard + :payload {:dashboard {:id 1 :name "Test Dashboard"} + :parameters [] + :dashboard_parts [{:type :card :card {:id 1 :name "Good Card"} :dashcard {:id 10 :dashboard_id 1}} + {:type :card :card {:id 2 :name "Bad Card"} :dashcard {:id 20 :dashboard_id 1}}]} + :creator {:common_name "Test User"}} + recipient {:type :notification-recipient/raw-value :details {:value "#test-channel"}}] + (mt/with-dynamic-fn-redefs [slack/upload-file! (fn [_ _] {:id "uploaded-file-id"}) + channel.slack/part->sections! (fn [params part] + (if (= 2 (-> part :card :id)) + (throw (ex-info "boom rendering part" {})) + (orig params part)))] + (mt/with-temporary-setting-values [site-url "http://example.com"] + (let [blocks (-> (channel/render-notification :channel/slack notification {:recipients [recipient]}) + first :blocks) + all-text (str/join " " (keep #(-> % :text :text) blocks))] + (testing "the failed card degrades to the error placeholder block" + (is (str/includes? all-text "An error occurred while displaying this card."))) + (testing "the healthy card still produced its block (delivery not aborted)" + (is (str/includes? all-text "Good Card"))))))))) diff --git a/test/metabase/channel/render/table_test.clj b/test/metabase/channel/render/table_test.clj index 7e93e2f5791e..2c9402ec5376 100644 --- a/test/metabase/channel/render/table_test.clj +++ b/test/metabase/channel/render/table_test.clj @@ -2,11 +2,14 @@ (:require [clojure.string :as str] [clojure.test :refer :all] + [hiccup.core :refer [html]] + [hickory.core :as hik] [hickory.select :as hik.s] [metabase.channel.render.core :as channel.render] [metabase.channel.render.js.color :as js.color] [metabase.channel.render.table :as table] [metabase.formatter.core :as formatter] + [metabase.models.visualization-settings :as mb.viz] [metabase.pulse.render.test-util :as render.tu] [metabase.test :as mt] [metabase.test.fixtures :as fixtures])) @@ -263,6 +266,34 @@ (testing "Minibar handles 0 gracefully" (is (some? (render.tu/render-card-as-hickory! card-id2)))))))) +(deftest table-minibar-missing-fingerprint-test + (testing "A minibar column whose fingerprint lacks numeric range stats renders the plain value + instead of crashing notification delivery (#74007)" + ;; A column can be numeric yet have no `:type/Number` fingerprint stats (an unsynced or + ;; computed column), leaving `min`/`max` nil. The bug surfaced only during notification + ;; delivery because `render-pulse-card` returns lazy hiccup: the NPE escaped its try/catch + ;; and fired later when the hiccup was realized into HTML. So we realize it here too. + (let [data {:cols [{:name "A" :display_name "A" :base_type :type/Integer :semantic_type nil}] + :rows [[5] [10]] + :format-rows? true + :results_metadata {:columns [{:name "A" :base_type :type/Integer + :fingerprint {:global {:distinct-count 2}}}]} + :viz-settings {::mb.viz/column-settings + {{::mb.viz/column-name "A"} {::mb.viz/show-mini-bar true}}}} + ;; render-pulse-card returns lazy hiccup, so realize it into HTML (as delivery does) + ;; before parsing; this is the step that throws on the unfixed code. + doc (-> (channel.render/render-pulse-card :inline "UTC" render.tu/test-card nil {:data data}) + :content + html + hik/parse + hik/as-hickory) + body-cells (hik.s/select (hik.s/descendant (hik.s/tag :tbody) (hik.s/tag :td)) doc)] + (testing "no minibar is rendered in any data cell - it falls back to the plain value" + (is (not-any? (fn [cell] (seq (hik.s/select (hik.s/tag :table) cell))) body-cells))) + (testing "the cell values are still rendered" + (is (seq (hik.s/select (hik.s/find-in-text #"^5$") doc))) + (is (seq (hik.s/select (hik.s/find-in-text #"^10$") doc))))))) + (defn- render-table [dashcard results] (channel.render/render-pulse-card :attachment "America/Los_Angeles" render.tu/test-card dashcard results))