From d6765a11dd0d2885eb45841fbf669e800e6d46bc Mon Sep 17 00:00:00 2001 From: Jeff Evans Date: Fri, 26 Mar 2021 17:13:21 -0500 Subject: [PATCH 1/7] Update MS SQL Server JDBC driver version (#15287) Bump mssql-jdbc version from 7.4.1.jre8 to 9.2.1.jre8 Bump plugin version accordingly Override prepared-statement and statement multimethods for :sqlserver to not set holdability at the statement level Fixing inaccurate log statements --- modules/drivers/sqlserver/project.clj | 4 +- .../sqlserver/resources/metabase-plugin.yaml | 2 +- .../src/metabase/driver/sqlserver.clj | 45 ++++++++++++++++++- src/metabase/driver/sql_jdbc/execute.clj | 7 +-- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/modules/drivers/sqlserver/project.clj b/modules/drivers/sqlserver/project.clj index c5c1fef73486f..6edcf61449ad3 100644 --- a/modules/drivers/sqlserver/project.clj +++ b/modules/drivers/sqlserver/project.clj @@ -1,8 +1,8 @@ -(defproject metabase/sqlserver-driver "1.0.0-SNAPSHOT-7.4.1.jre8" +(defproject metabase/sqlserver-driver "1.1.0-SNAPSHOT-9.2.1.jre8" :min-lein-version "2.5.0" :dependencies - [[com.microsoft.sqlserver/mssql-jdbc "7.4.1.jre8"]] + [[com.microsoft.sqlserver/mssql-jdbc "9.2.1.jre8"]] :profiles {:provided diff --git a/modules/drivers/sqlserver/resources/metabase-plugin.yaml b/modules/drivers/sqlserver/resources/metabase-plugin.yaml index ae909c8538738..9279606c45e24 100644 --- a/modules/drivers/sqlserver/resources/metabase-plugin.yaml +++ b/modules/drivers/sqlserver/resources/metabase-plugin.yaml @@ -1,6 +1,6 @@ info: name: Metabase SQL Server Driver - version: 1.0.0-SNAPSHOT-7.4.1-jre8 + version: 1.1.0-SNAPSHOT-9.2.1.jre8 description: Allows Metabase to connect to SQL Server databases. driver: name: sqlserver diff --git a/modules/drivers/sqlserver/src/metabase/driver/sqlserver.clj b/modules/drivers/sqlserver/src/metabase/driver/sqlserver.clj index 118c140b9968b..955cc33894755 100644 --- a/modules/drivers/sqlserver/src/metabase/driver/sqlserver.clj +++ b/modules/drivers/sqlserver/src/metabase/driver/sqlserver.clj @@ -1,6 +1,7 @@ (ns metabase.driver.sqlserver "Driver for SQLServer databases. Uses the official Microsoft JDBC driver under the hood (pre-0.25.0, used jTDS)." - (:require [honeysql.core :as hsql] + (:require [clojure.tools.logging :as log] + [honeysql.core :as hsql] [honeysql.helpers :as h] [java-time :as t] [metabase.config :as config] @@ -15,7 +16,8 @@ [metabase.driver.sql.util.unprepare :as unprepare] [metabase.mbql.util :as mbql.u] [metabase.query-processor.interface :as qp.i] - [metabase.util.honeysql-extensions :as hx]) + [metabase.util.honeysql-extensions :as hx] + [metabase.util.i18n :refer [trs]]) (:import [java.sql Connection ResultSet Time Types] [java.time LocalDate LocalDateTime LocalTime OffsetDateTime OffsetTime ZonedDateTime] java.util.Date)) @@ -449,3 +451,42 @@ (defmethod sql/->prepared-substitution [:sqlserver Boolean] [driver bool] (sql/->prepared-substitution driver (if bool 1 0))) + +;; SQL server only supports setting holdability at the connection level, not the statement level, as per +;; https://docs.microsoft.com/en-us/sql/connect/jdbc/using-holdability?view=sql-server-ver15 +;; and +;; https://github.com/microsoft/mssql-jdbc/blob/v9.2.1/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java#L5349-L5357 +;; an exception is thrown if they do not match, so it's safer to simply NOT try to override it at the statement level, +;; because it's not supported anyway +(defmethod sql-jdbc.execute/prepared-statement :sqlserver + [driver ^Connection conn ^String sql params] + (let [stmt (.prepareStatement conn + sql + ResultSet/TYPE_FORWARD_ONLY + ResultSet/CONCUR_READ_ONLY)] + (try + (try + (.setFetchDirection stmt ResultSet/FETCH_FORWARD) + (catch Throwable e + (log/debug e (trs "Error setting prepared statement fetch direction to FETCH_FORWARD")))) + (sql-jdbc.execute/set-parameters! driver stmt params) + stmt + (catch Throwable e + (.close stmt) + (throw e))))) + +;; similar rationale to prepared-statement above +(defmethod sql-jdbc.execute/statement :sqlserver + [_ ^Connection conn] + (let [stmt (.createStatement conn + ResultSet/TYPE_FORWARD_ONLY + ResultSet/CONCUR_READ_ONLY)] + (try + (try + (.setFetchDirection stmt ResultSet/FETCH_FORWARD) + (catch Throwable e + (log/debug e (trs "Error setting statement fetch direction to FETCH_FORWARD")))) + stmt + (catch Throwable e + (.close stmt) + (throw e))))) diff --git a/src/metabase/driver/sql_jdbc/execute.clj b/src/metabase/driver/sql_jdbc/execute.clj index d9b81287442a7..5aa9677b28809 100644 --- a/src/metabase/driver/sql_jdbc/execute.clj +++ b/src/metabase/driver/sql_jdbc/execute.clj @@ -259,7 +259,8 @@ (defmethod prepared-statement :sql-jdbc [driver ^Connection conn ^String sql params] - (let [stmt (.prepareStatement conn sql + (let [stmt (.prepareStatement conn + sql ResultSet/TYPE_FORWARD_ONLY ResultSet/CONCUR_READ_ONLY ResultSet/CLOSE_CURSORS_AT_COMMIT)] @@ -267,7 +268,7 @@ (try (.setFetchDirection stmt ResultSet/FETCH_FORWARD) (catch Throwable e - (log/debug e (trs "Error setting result set fetch direction to FETCH_FORWARD")))) + (log/debug e (trs "Error setting prepared statement fetch direction to FETCH_FORWARD")))) (set-parameters! driver stmt params) stmt (catch Throwable e @@ -289,7 +290,7 @@ (try (.setFetchDirection stmt ResultSet/FETCH_FORWARD) (catch Throwable e - (log/debug e (trs "Error setting result set fetch direction to FETCH_FORWARD")))) + (log/debug e (trs "Error setting statement fetch direction to FETCH_FORWARD")))) stmt (catch Throwable e (.close stmt) From 922b90363a4b1e902904f5d76f41dd5cfd0f6531 Mon Sep 17 00:00:00 2001 From: Dalton Date: Mon, 29 Mar 2021 09:20:42 -0700 Subject: [PATCH 2/7] add default param value selector (#15361) --- frontend/src/metabase/dashboard/selectors.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/metabase/dashboard/selectors.js b/frontend/src/metabase/dashboard/selectors.js index 3dd089abc22a1..9cf24992611ec 100644 --- a/frontend/src/metabase/dashboard/selectors.js +++ b/frontend/src/metabase/dashboard/selectors.js @@ -251,3 +251,15 @@ export const makeGetParameterMappingOptions = () => { ); return getParameterMappingOptions; }; + +export const getDefaultParametersById = createSelector( + [getDashboard], + dashboard => + ((dashboard && dashboard.parameters) || []).reduce((map, parameter) => { + if (parameter.default) { + map[parameter.id] = parameter.default; + } + + return map; + }, {}), +); From bc92d8ca08135124ee2e2254ccbe203e394c48e0 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Mon, 29 Mar 2021 09:42:56 -0700 Subject: [PATCH 3/7] Check to see if `done` button is enabled whenever custom expr changes (#15293) This is done by firing an event on change which mutates if blankness of the input changes --- .../query_builder/components/ExpressionPopover.jsx | 14 +++++++++----- .../expressions/ExpressionEditorTextfield.jsx | 4 ++++ .../metabase/scenarios/question/filter.cy.spec.js | 3 ++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/src/metabase/query_builder/components/ExpressionPopover.jsx b/frontend/src/metabase/query_builder/components/ExpressionPopover.jsx index f4aab74634e35..8b82bfe30ef4e 100644 --- a/frontend/src/metabase/query_builder/components/ExpressionPopover.jsx +++ b/frontend/src/metabase/query_builder/components/ExpressionPopover.jsx @@ -12,6 +12,7 @@ import "./ExpressionPopover.css"; export default class ExpressionPopover extends React.Component { state = { error: null, + isBlank: true, }; render() { @@ -26,11 +27,11 @@ export default class ExpressionPopover extends React.Component { name, onChangeName, } = this.props; - const { error } = this.state; + const { error, isBlank } = this.state; - // if onChangeName is provided then a name is required - const isValid = !error && (!onChangeName || name); + const buttonEnabled = !error && !isBlank && (!onChangeName || name); + // if onChangeName is provided then a name is required return (
@@ -56,6 +57,9 @@ export default class ExpressionPopover extends React.Component { onDone(expression); } }} + onBlankChange={newBlank => { + this.setState({ isBlank: newBlank }); + }} /> {onChangeName && ( onChangeName(e.target.value)} onKeyPress={e => { - if (e.key === "Enter" && isValid) { + if (e.key === "Enter" && buttonEnabled) { onDone(expression); } }} @@ -73,7 +77,7 @@ export default class ExpressionPopover extends React.Component {
+ } + > + foo foo foo foo foo + + ), + "Header and body classes": ( + +
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus neque + tellus, mattis ut felis non, tempus mollis lacus. Vivamus nulla massa, + accumsan non ligula eu, dapibus volutpat libero. Mauris sollicitudin + dolor et ipsum fringilla auctor. Praesent et diam non nisi consequat + ornare. Aenean et risus vel dolor maximus dapibus a id massa. Nam + finibus quis libero eu finibus. Sed vehicula ac enim pellentesque + luctus. Phasellus vehicula et ipsum porttitor mollis. Fusce blandit + lacus a elit pretium, vestibulum porta nisi vehicula. Aliquam vel ligula + enim. Orci varius natoque penatibus et magnis dis parturient montes, + nascetur ridiculus mus. Pellentesque eget porta mi. Duis et lectus eget + dolor convallis mollis. Sed commodo nec urna eget egestas. +
+
+ Mauris in ante sit amet ipsum tempus consequat. Curabitur auctor massa + vitae dui auctor scelerisque. Donec in leo a libero commodo sodales. + Integer egestas lacinia elit, vitae cursus sem mollis ut. Proin ut + dapibus metus, vel accumsan justo. Pellentesque eget finibus elit, ut + commodo felis. Ut non lacinia metus. Maecenas eget bibendum nisl. +
+
+ ), +}; diff --git a/frontend/src/metabase/components/CollapseSection.jsx b/frontend/src/metabase/components/CollapseSection.jsx new file mode 100644 index 0000000000000..f5cafd214ad57 --- /dev/null +++ b/frontend/src/metabase/components/CollapseSection.jsx @@ -0,0 +1,70 @@ +/* eslint "react/prop-types": 2 */ + +import React, { useState } from "react"; +import PropTypes from "prop-types"; +import cx from "classnames"; + +import Icon from "metabase/components/Icon"; + +function CollapseSection({ + children, + className, + header, + headerClass, + bodyClass, + initialState = "collapsed", +}) { + const [isExpanded, setIsExpanded] = useState(initialState === "expanded"); + + return ( +
+
setIsExpanded(isExpanded => !isExpanded)} + onKeyDown={e => + e.key === "Enter" && setIsExpanded(isExpanded => !isExpanded) + } + > + + + {header} + +
+
+ {isExpanded && ( +
+ {children} +
+ )} +
+
+ ); +} + +CollapseSection.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + header: PropTypes.node, + headerClass: PropTypes.string, + bodyClass: PropTypes.string, + initialState: PropTypes.oneOf(["expanded", "collapsed"]), +}; + +export default CollapseSection; From f10e9cf2d0b90b278f4d3315b3bd12f5b2b9d7a0 Mon Sep 17 00:00:00 2001 From: Dalton Date: Mon, 29 Mar 2021 14:47:27 -0700 Subject: [PATCH 5/7] add string/number operator subtypes to dashboard parameter filters (#15068) * Remove location sub-categories These sub-categories are only for filtering the list of options when mapping a parameter filter to a field. Since we are introducing operator types as a sub-category of location, city/zip/etc. just get in the way. * add number section + number/string operator subtypes Light refactor of meta/Dashboard changes rmv 'all-options' options (for now) * add/update parameter type icons * pass operator to ParameterFieldWidget + show input per operator field * Add operator helper fns that aren't dependent on fields/tables * Make operator prop optional fix date filter err * add combined name for native question filter widget type list Otherwise, a field that matches both "Location" and "Category" options will show duplicate "Starts with" options, etc. Now, that'll look like "Category - Starts with" and "Location - Starts with" * correct some unused prop/arg passing * Convert location/category parameter types to string for query location/category don't mean anything to BE but we use them for "reasons" on the FE. Reasons are legacy reliance on unique-ness of the parameter.type value, primarily. * operators in backend * Remove errant tap> * Docstrings and differing numbers in tests in some dbs * Make unary private so docstring checker ~shuts-up~ is satisfied * Don't parse arguments to operators params they were coming in just fine from the FE as numeric or string types. no need to ensure strings everywhere and parse here * add max-width to PopoverPicker * rmv unused value * use combinedName on dashboard parameters * fix parameter to mbql code * Ensure = operator filter popovers have no label This is to match "old" style of parameter popover * Update Cypress tests to reflect new parameter flow fix cypress dashboard parameter tests Fix more cy tests * Don't call fk/joinAlias on ExpressionDimension The methods don't exist on ExpressionDimension class. This doesn't make them work (yet), but it prevents the app from crashing. * Namespace doc and remove unnecessary comment * tap>-spy in dev * first pass at substitution of new operators in native * Docstring on wrap-value-literals-in-mbql to appease the gods * variadic equality operators (string/= number/=) * move functions out of component file * Pass parameter object to tag editor for use in default input We should inline this input eventually because it looks ugly. * map parameters in Questions to correct type * continue to pass janky fake parameter for text/number tags * mongo native substitution * variadic not-equals for string and number * Docstring and use correct function to make errors * add number/between dash param cy test * Update function name to better reflect behavior * Add unit tests for paramer/operator util fns add unit tests for parameter util functions add unit test for operator util fns * add variadic string 'is not' param operator option * Modify operator parameter display labels don't append 'matches exactly' to location/category parameters label tweaks Update cy tests to reference correct label name rmv it.only * Desugar mongo parameters mbql desugaring makes for a bit more verbose query but that's ok.This change was done to ensure that we negated regexes in a correct way, and to do so we always return the string version. This ensures that it can be json/generate-string'd for native parameters or left as datastructures and sent to monger * Cleanup stale comments and fixup docstring for consistency * Arglists metadata on defmulti and denude some threaded forms * add single arity number tag predicate to variable filter * add Location operators to fix parameter<->filter mapping For question filters to work we need for the new parameter operators to be supported by "location" fields in all areas of the app. * Don't show coords for param number widgets I don't think we want to support all the various number operators when dealing with coordinates, so in order to avoid that I'm preventing the mapping of number parameter operator to coord fields. * prevent mapping of tags to non-equal operators while possibly useful to end users, this needs more UI work on the native question side of things. * Ensure parameter values are wrapped in an array When an = operator is mapped to a field AND a tag, it ends up not being wrapped in an array due to the TextWidget (I think). ensure parameter value is an array ensure number params have an array value * Sort imports correctly clojure-lsp used to do this incorrectly (sorting `[` before `j`) and that has now been fixed Co-authored-by: dan sutton --- dev/src/dev.clj | 3 + frontend/src/metabase-lib/lib/Question.js | 10 +- .../components/ParametersPopover.jsx | 21 +- frontend/src/metabase/lib/schema_metadata.js | 30 ++ frontend/src/metabase/meta/Card.js | 18 +- frontend/src/metabase/meta/Dashboard.js | 14 +- frontend/src/metabase/meta/Parameter.js | 264 ++++++++++++++---- .../components/ParameterValueWidget.jsx | 13 +- .../widgets/ParameterFieldWidget.jsx | 118 +++++--- .../template_tags/TagEditorParam.jsx | 21 +- .../template_tags/TagEditorSidebar.jsx | 6 + .../metabase/lib/schema_metadata.unit.spec.js | 37 +++ frontend/test/metabase/meta/Card.unit.spec.js | 18 +- .../test/metabase/meta/Parameter.unit.spec.js | 203 +++++++++++++- .../dashboard/chained-filters.cy.spec.js | 12 +- .../scenarios/dashboard/dashboard.cy.spec.js | 5 +- .../dashboard_data_permissions.cy.spec.js | 6 +- .../scenarios/dashboard/embed.cy.spec.js | 5 +- .../scenarios/dashboard/parameters.cy.spec.js | 75 ++++- .../src/metabase/driver/mongo/parameters.clj | 40 ++- .../metabase/driver/mongo/query_processor.clj | 10 +- .../metabase/driver/mongo/parameters_test.clj | 152 +++++++++- .../driver/common/parameters/operators.clj | 79 ++++++ .../driver/sql/parameters/substitution.clj | 52 ++-- .../middleware/parameters/mbql.clj | 19 +- .../middleware/wrap_value_literals.clj | 15 +- .../common/parameters/operators_test.clj | 65 +++++ .../driver/sql/parameters/substitute_test.clj | 58 +++- .../middleware/parameters/mbql_test.clj | 78 +++++- 29 files changed, 1259 insertions(+), 188 deletions(-) create mode 100644 src/metabase/driver/common/parameters/operators.clj create mode 100644 test/metabase/driver/common/parameters/operators_test.clj diff --git a/dev/src/dev.clj b/dev/src/dev.clj index db28f2f20a014..aad0b5e49a9e3 100644 --- a/dev/src/dev.clj +++ b/dev/src/dev.clj @@ -22,6 +22,9 @@ (comment debug-qp/keep-me) +(defn tap>-spy [x] + (doto x tap>)) + (p/import-vars [debug-qp process-query-debug]) diff --git a/frontend/src/metabase-lib/lib/Question.js b/frontend/src/metabase-lib/lib/Question.js index be567e7390a18..0cb5c8a8e6025 100644 --- a/frontend/src/metabase-lib/lib/Question.js +++ b/frontend/src/metabase-lib/lib/Question.js @@ -31,7 +31,10 @@ import * as Card_DEPRECATED from "metabase/lib/card"; import * as Urls from "metabase/lib/urls"; import { syncTableColumnsToQuery } from "metabase/lib/dataset"; import { getParametersWithExtras, isTransientId } from "metabase/meta/Card"; -import { parameterToMBQLFilter } from "metabase/meta/Parameter"; +import { + parameterToMBQLFilter, + mapUIParameterToQueryParameter, +} from "metabase/meta/Parameter"; import { aggregate, breakout, @@ -823,7 +826,10 @@ export default class Question { // include only parameters that have a value applied .filter(param => _.has(param, "value")) // only the superset of parameters object that API expects - .map(param => _.pick(param, "type", "target", "value")); + .map(param => _.pick(param, "type", "target", "value")) + .map(({ type, value, target }) => { + return mapUIParameterToQueryParameter(type, value, target); + }); if (canUseCardApiEndpoint) { const queryParams = { diff --git a/frontend/src/metabase/dashboard/components/ParametersPopover.jsx b/frontend/src/metabase/dashboard/components/ParametersPopover.jsx index b0740a46d2ee6..fcfa5945478c2 100644 --- a/frontend/src/metabase/dashboard/components/ParametersPopover.jsx +++ b/frontend/src/metabase/dashboard/components/ParametersPopover.jsx @@ -1,6 +1,9 @@ import React, { Component } from "react"; import { t } from "ttag"; import { PARAMETER_SECTIONS } from "metabase/meta/Dashboard"; +import Icon from "metabase/components/Icon"; +import { getParameterIconName } from "metabase/meta/Parameter"; +import styled from "styled-components"; import type { Parameter, @@ -11,6 +14,10 @@ import _ from "underscore"; import type { ParameterSection } from "metabase/meta/Dashboard"; +const PopoverBody = styled.div` + max-width: 300px; +`; + export default class ParametersPopover extends Component { props: { onAddParameter: (option: ParameterOption) => Promise, @@ -68,7 +75,11 @@ export const ParameterOptionsSection = ({ onClick: () => any, }) => (
  • -
    +
    + {section.name}
    {section.description}
    @@ -82,7 +93,7 @@ export const ParameterOptionsSectionsPane = ({ sections: Array, onSelectSection: ParameterSection => any, }) => ( -
    +

    {t`What do you want to filter?`}

      {sections.map(section => ( @@ -92,7 +103,7 @@ export const ParameterOptionsSectionsPane = ({ /> ))}
    -
    + ); export const ParameterOptionItem = ({ @@ -117,7 +128,7 @@ export const ParameterOptionsPane = ({ options: ?Array, onSelectOption: ParameterOption => any, }) => ( -
    +

    {t`What kind of filter?`}

      {options && @@ -128,5 +139,5 @@ export const ParameterOptionsPane = ({ /> ))}
    -
    + ); diff --git a/frontend/src/metabase/lib/schema_metadata.js b/frontend/src/metabase/lib/schema_metadata.js index 9caf12883d77f..3e4f99ee5b884 100644 --- a/frontend/src/metabase/lib/schema_metadata.js +++ b/frontend/src/metabase/lib/schema_metadata.js @@ -441,6 +441,10 @@ const FILTER_OPERATORS_BY_TYPE_ORDERED = { { name: "!=", verboseName: t`Is not` }, { name: "is-null", verboseName: t`Is empty` }, { name: "not-null", verboseName: t`Not empty` }, + { name: "contains", verboseName: t`Contains` }, + { name: "does-not-contain", verboseName: t`Does not contain` }, + { name: "starts-with", verboseName: t`Starts with` }, + { name: "ends-with", verboseName: t`Ends with` }, ], [COORDINATE]: [ { name: "=", verboseName: t`Is` }, @@ -470,6 +474,28 @@ const MORE_VERBOSE_NAMES = { "greater than or equal to": "is greater than or equal to", }; +export function doesOperatorExist(operatorName) { + return !!FIELD_FILTER_OPERATORS[operatorName]; +} + +export function getOperatorByTypeAndName(type, name) { + const typedNamedOperator = _.findWhere( + FILTER_OPERATORS_BY_TYPE_ORDERED[type], + { + name, + }, + ); + const namedOperator = FIELD_FILTER_OPERATORS[name]; + + return ( + typedNamedOperator && { + ...typedNamedOperator, + ...namedOperator, + numFields: namedOperator.validArgumentsFilters.length, + } + ); +} + export function getFilterOperators(field, table, selected) { const type = getFieldType(field) || UNKNOWN; return FILTER_OPERATORS_BY_TYPE_ORDERED[type] @@ -721,3 +747,7 @@ export function getFilterArgumentFormatOptions(filterOperator, index) { {} ); } + +export function isEqualsOperator(operator) { + return !!operator && operator.name === "="; +} diff --git a/frontend/src/metabase/meta/Card.js b/frontend/src/metabase/meta/Card.js index 0d63454ef56e2..c996d0ca06ed8 100644 --- a/frontend/src/metabase/meta/Card.js +++ b/frontend/src/metabase/meta/Card.js @@ -2,6 +2,7 @@ import { getTemplateTagParameters, getParameterTargetFieldId, parameterToMBQLFilter, + mapUIParameterToQueryParameter, } from "metabase/meta/Parameter"; import * as Query from "metabase/lib/query/query"; @@ -190,20 +191,17 @@ export function applyParameters( parameter_id: parameter.id, }, ); + if (mapping) { // mapped target, e.x. on a dashboard - datasetQuery.parameters.push({ - type: parameter.type, - target: mapping.target, - value: value, - }); + datasetQuery.parameters.push( + mapUIParameterToQueryParameter(parameter.type, value, mapping.target), + ); } else if (parameter.target) { // inline target, e.x. on a card - datasetQuery.parameters.push({ - type: parameter.type, - target: parameter.target, - value: value, - }); + datasetQuery.parameters.push( + mapUIParameterToQueryParameter(parameter.type, value, parameter.target), + ); } } diff --git a/frontend/src/metabase/meta/Dashboard.js b/frontend/src/metabase/meta/Dashboard.js index 1c177cd8bc623..7b2f0c0e6752d 100644 --- a/frontend/src/metabase/meta/Dashboard.js +++ b/frontend/src/metabase/meta/Dashboard.js @@ -1,4 +1,5 @@ import Question from "metabase-lib/lib/Question"; +import { ExpressionDimension } from "metabase-lib/lib/Dimension"; import type Metadata from "metabase-lib/lib/metadata/Metadata"; import type { Card } from "metabase-types/types/Card"; @@ -45,6 +46,12 @@ export const PARAMETER_SECTIONS: ParameterSection[] = [ description: t`User ID, product ID, event ID, etc.`, options: [], }, + { + id: "number", + name: t`Number`, + description: t`Subtotal, Age, Price, Quantity, etc.`, + options: [], + }, { id: "category", name: t`Other Categories`, @@ -92,7 +99,10 @@ export function getParameterMappingOptions( name: dimension.displayName(), icon: dimension.icon(), target: ["dimension", dimension.mbql()], - isForeign: !!(dimension.fk() || dimension.joinAlias()), + // these methods don't exist on instances of ExpressionDimension + isForeign: !!(dimension instanceof ExpressionDimension + ? false + : dimension.fk() || dimension.joinAlias()), })), ), ); @@ -133,7 +143,7 @@ export function createParameter( option: ParameterOption, parameters: Parameter[] = [], ): Parameter { - let name = option.name; + let name = option.combinedName || option.name; let nameIndex = 0; // get a unique name while (_.any(parameters, p => p.name === name)) { diff --git a/frontend/src/metabase/meta/Parameter.js b/frontend/src/metabase/meta/Parameter.js index 872780455905c..ccb8dacc956e4 100644 --- a/frontend/src/metabase/meta/Parameter.js +++ b/frontend/src/metabase/meta/Parameter.js @@ -21,8 +21,17 @@ import type Field from "metabase-lib/lib/metadata/Field"; import Dimension, { FieldDimension } from "metabase-lib/lib/Dimension"; import moment from "moment"; import { t } from "ttag"; +import _ from "underscore"; import * as FIELD_REF from "metabase/lib/query/field_ref"; -import { isNumericBaseType } from "metabase/lib/schema_metadata"; +import { + isNumericBaseType, + doesOperatorExist, + getOperatorByTypeAndName, + STRING, + NUMBER, + PRIMARY_KEY, +} from "metabase/lib/schema_metadata"; + import Variable, { TemplateTagVariable } from "metabase-lib/lib/Variable"; type DimensionFilter = (dimension: Dimension) => boolean; @@ -30,6 +39,91 @@ type TemplateTagFilter = (tag: TemplateTag) => boolean; type FieldPredicate = (field: Field) => boolean; type VariableFilter = (variable: Variable) => boolean; +export const PARAMETER_OPERATOR_TYPES = { + number: [ + { + operator: "=", + name: t`Equal to`, + }, + { + operator: "!=", + name: t`Not equal to`, + }, + { + operator: "between", + name: t`Between`, + }, + { + operator: ">=", + name: t`Greater than or equal to`, + }, + { + operator: "<=", + name: t`Less than or equal to`, + }, + // { + // operator: "all-options", + // name: t`All options`, + // description: t`Contains all of the above`, + // }, + ], + string: [ + { + operator: "=", + name: t`Dropdown`, + description: t`Select one or more values from a list or search box.`, + }, + { + operator: "!=", + name: t`Is not`, + description: t`Exclude one or more specific values.`, + }, + { + operator: "contains", + name: t`Contains`, + description: t`Match values that contain the entered text.`, + }, + { + operator: "does-not-contain", + name: t`Does not contain`, + description: t`Filter out values that contain the entered text.`, + }, + { + operator: "starts-with", + name: t`Starts with`, + description: t`Match values that begin with the entered text.`, + }, + { + operator: "ends-with", + name: t`Ends with`, + description: t`Match values that end with the entered text.`, + }, + // { + // operator: "all-options", + // name: t`All options`, + // description: t`Users can pick from any of the above`, + // }, + ], +}; + +const OPTIONS_WITH_OPERATOR_SUBTYPES = [ + { + section: "location", + operatorType: "string", + sectionName: t`Location`, + }, + { + section: "category", + operatorType: "string", + sectionName: t`Category`, + }, + { + section: "number", + operatorType: "number", + sectionName: t`Number`, + }, +]; + export const PARAMETER_OPTIONS: ParameterOption[] = [ { type: "date/month-year", @@ -62,31 +156,27 @@ export const PARAMETER_OPTIONS: ParameterOption[] = [ menuName: t`All Options`, description: t`Contains all of the above`, }, - { - type: "location/city", - name: t`City`, - }, - { - type: "location/state", - name: t`State`, - }, - { - type: "location/zip_code", - name: t`ZIP or Postal Code`, - }, - { - type: "location/country", - name: t`Country`, - }, { type: "id", name: t`ID`, }, - { - type: "category", - name: t`Category`, - }, -]; + ...OPTIONS_WITH_OPERATOR_SUBTYPES.map(option => + buildOperatorSubtypeOptions(option), + ), +].flat(); + +function buildOperatorSubtypeOptions({ section, operatorType, sectionName }) { + return PARAMETER_OPERATOR_TYPES[operatorType].map(option => ({ + ...option, + combinedName: + operatorType === "string" && option.operator === "=" + ? `${sectionName}` + : sectionName === "Number" + ? `${option.name}` + : `${sectionName} ${option.name.toLowerCase()}`, + type: `${section}/${option.operator}`, + })); +} function fieldFilterForParameter(parameter: Parameter) { return fieldFilterForParameterType(parameter.type); @@ -95,7 +185,7 @@ function fieldFilterForParameter(parameter: Parameter) { function fieldFilterForParameterType( parameterType: ParameterType, ): FieldPredicate { - const [type] = parameterType.split("/"); + const [type] = splitType(parameterType); switch (type) { case "date": return (field: Field) => field.isDate(); @@ -103,25 +193,28 @@ function fieldFilterForParameterType( return (field: Field) => field.isID(); case "category": return (field: Field) => field.isCategory(); + case "location": + return (field: Field) => + field.isCity() || + field.isState() || + field.isZipCode() || + field.isCountry(); + case "number": + return (field: Field) => field.isNumber() && !field.isCoordinate(); } - switch (parameterType) { - case "location/city": - return (field: Field) => field.isCity(); - case "location/state": - return (field: Field) => field.isState(); - case "location/zip_code": - return (field: Field) => field.isZipCode(); - case "location/country": - return (field: Field) => field.isCountry(); - } return (field: Field) => false; } export function parameterOptionsForField(field: Field): ParameterOption[] { return PARAMETER_OPTIONS.filter(option => fieldFilterForParameterType(option.type)(field), - ); + ).map(option => { + return { + ...option, + name: option.combinedName || option.name, + }; + }); } export function dimensionFilterForParameter( @@ -145,7 +238,12 @@ export function variableFilterForParameter( } function tagFilterForParameter(parameter: Parameter): TemplateTagFilter { - const [type, subtype] = parameter.type.split("/"); + const [type, subtype] = splitType(parameter); + const operator = getParameterOperatorName(subtype); + if (operator !== "=") { + return (tag: TemplateTag) => false; + } + switch (type) { case "date": return (tag: TemplateTag) => subtype === "single" && tag.type === "date"; @@ -155,6 +253,8 @@ function tagFilterForParameter(parameter: Parameter): TemplateTagFilter { return (tag: TemplateTag) => tag.type === "number" || tag.type === "text"; case "category": return (tag: TemplateTag) => tag.type === "number" || tag.type === "text"; + case "number": + return (tag: TemplateTag) => tag.type === "number"; } return (tag: TemplateTag) => false; } @@ -314,19 +414,25 @@ export function dateParameterValueToMBQL( } export function stringParameterValueToMBQL( - parameterValue: ParameterValueOrArray, + parameter: Parameter, fieldRef: LocalFieldReference | ForeignFieldReference, ): ?FieldFilter { - // $FlowFixMe: thinks we're returning a nested array which concat does not do - return ["=", fieldRef].concat(parameterValue); + const parameterValue: ParameterValueOrArray = parameter.value; + const [, subtype] = splitType(parameter); + const operatorName = getParameterOperatorName(subtype); + + return [operatorName, fieldRef].concat(parameterValue); } export function numberParameterValueToMBQL( - parameterValue: ParameterValue, + parameter: ParameterInstance, fieldRef: LocalFieldReference | ForeignFieldReference, ): ?FieldFilter { - // $FlowFixMe: thinks we're returning a nested array which concat does not do - return ["=", fieldRef].concat( + const parameterValue: ParameterValue = parameter.value; + const [, subtype] = splitType(parameter); + const operatorName = getParameterOperatorName(subtype); + + return [operatorName, fieldRef].concat( [].concat(parameterValue).map(v => parseFloat(v)), ); } @@ -356,19 +462,81 @@ export function parameterToMBQLFilter( const field = metadata.field(fieldId); // if the field is numeric, parse the value as a number if (isNumericBaseType(field)) { - return numberParameterValueToMBQL(parameter.value, fieldRef); + return numberParameterValueToMBQL(parameter, fieldRef); } else { - return stringParameterValueToMBQL(parameter.value, fieldRef); + return stringParameterValueToMBQL(parameter, fieldRef); } } } export function getParameterIconName(parameterType: ?ParameterType) { - if (/^date\//.test(parameterType || "")) { - return "calendar"; - } else if (/^location\//.test(parameterType || "")) { - return "location"; + const [type] = splitType(parameterType); + switch (type) { + case "date": + return "calendar"; + case "location": + return "location"; + case "category": + return "string"; + case "number": + return "number"; + case "id": + default: + return "label"; + } +} + +export function mapUIParameterToQueryParameter(type, value, target) { + const [fieldType, maybeOperatorName] = splitType(type); + const operatorName = getParameterOperatorName(maybeOperatorName); + + if (fieldType === "location" || fieldType === "category") { + return { + type: `string/${operatorName}`, + value: [].concat(value), + target, + }; + } else if (fieldType === "number") { + return { + type, + value: [].concat(value), + target, + }; } else { - return "label"; + return { type, value, target }; } } + +function getParameterOperatorName(maybeOperatorName) { + return doesOperatorExist(maybeOperatorName) ? maybeOperatorName : "="; +} + +export function deriveFieldOperatorFromParameter(parameter) { + const [parameterType, maybeOperatorName] = splitType(parameter); + const operatorType = getParameterOperatorType(parameterType); + const operatorName = getParameterOperatorName(maybeOperatorName); + + return getOperatorByTypeAndName(operatorType, operatorName); +} + +function getParameterOperatorType(parameterType) { + switch (parameterType) { + case "number": + return NUMBER; + case "location": + case "category": + return STRING; + case "id": + // id can technically be a FK but doesn't matter as both use default filter operators + return PRIMARY_KEY; + default: + return undefined; + } +} + +function splitType(parameterOrType) { + const parameterType = _.isString(parameterOrType) + ? parameterOrType + : (parameterOrType || {}).type || ""; + return parameterType.split("/"); +} diff --git a/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx b/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx index 0a9ac1e07db77..390d7a8c8c4af 100644 --- a/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx +++ b/frontend/src/metabase/parameters/components/ParameterValueWidget.jsx @@ -22,7 +22,10 @@ import { makeGetMergedParameterFieldValues, } from "metabase/selectors/metadata"; -import { getParameterIconName } from "metabase/meta/Parameter"; +import { + getParameterIconName, + deriveFieldOperatorFromParameter, +} from "metabase/meta/Parameter"; import S from "./ParameterWidget.css"; @@ -138,7 +141,6 @@ export default class ParameterValueWidget extends Component { metadata, parameter, value, - values, setValue, isEditing, placeholder, @@ -191,9 +193,7 @@ export default class ParameterValueWidget extends Component { > {showTypeIcon && }
    - {hasValue - ? WidgetDefinition.format(value, values) - : placeholderText} + {hasValue ? WidgetDefinition.format(value) : placeholderText}
    ); } else { diff --git a/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx b/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx index 716efd35fe0ad..7768fbb9aeffe 100644 --- a/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx +++ b/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx @@ -2,6 +2,7 @@ import React, { Component } from "react"; import ReactDOM from "react-dom"; import { t, ngettext, msgid } from "ttag"; +import _ from "underscore"; import FieldValuesWidget from "metabase/components/FieldValuesWidget"; import Popover from "metabase/components/Popover"; @@ -12,6 +13,12 @@ import Field from "metabase-lib/lib/metadata/Field"; import type { Parameter } from "metabase-types/types/Parameter"; import type { DashboardWithCards } from "metabase-types/types/Dashboard"; +import type { FilterOperator } from "metabase-types/types/Metadata"; +import cx from "classnames"; +import { + getFilterArgumentFormatOptions, + isEqualsOperator, +} from "metabase/lib/schema_metadata"; type Props = { value: any, @@ -22,9 +29,11 @@ type Props = { fields: Field[], parentFocusChanged: boolean => void, + operator?: FilterOperator, dashboard?: DashboardWithCards, parameter?: Parameter, parameters?: Parameter[], + placeholder?: string, }; type State = { @@ -91,11 +100,21 @@ export default class ParameterFieldWidget extends Component<*, Props, State> { } render() { - const { setValue, isEditing, fields, parentFocusChanged } = this.props; - const { isFocused } = this.state; - + const { + setValue, + isEditing, + fields, + parentFocusChanged, + operator, + parameter, + parameters, + dashboard, + } = this.props; + const { isFocused, widgetWidth } = this.state; + const { numFields = 1, multi = false, verboseName } = operator || {}; const savedValue = normalizeValue(this.props.value); const unsavedValue = normalizeValue(this.state.value); + const isEqualsOp = isEqualsOperator(operator); const defaultPlaceholder = isFocused ? "" @@ -138,42 +157,63 @@ export default class ParameterFieldWidget extends Component<*, Props, State> { hasArrow={false} onClose={() => focusChanged(false)} > - { - this.setState({ value }); - }} - placeholder={placeholder} - fields={fields} - multi - autoFocus - color="brand" - style={{ - borderWidth: BORDER_WIDTH, - minWidth: this.state.widgetWidth - ? this.state.widgetWidth + BORDER_WIDTH * 2 - : null, - }} - className="border-bottom" - minWidth={400} - maxWidth={400} - /> - {/* border between input and footer comes from border-bottom on FieldValuesWidget */} -
    - +
    + {verboseName && !isEqualsOp && ( +
    {verboseName}...
    + )} + + {_.times(numFields, index => { + const value = multi ? unsavedValue : [unsavedValue[index]]; + const onValueChange = multi + ? newValues => this.setState({ value: newValues }) + : ([value]) => { + const newValues = [...unsavedValue]; + newValues[index] = value; + this.setState({ value: newValues }); + }; + return ( + + ); + })} + {/* border between input and footer comes from border-bottom on FieldValuesWidget */} +
    + +
    ); diff --git a/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx b/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx index 99342b7056d05..7bac8e446fffa 100644 --- a/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx +++ b/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.jsx @@ -106,7 +106,7 @@ export default class TagEditorParam extends Component { } render() { - const { tag, database, databases, metadata } = this.props; + const { tag, database, databases, metadata, parameter } = this.props; let widgetOptions = [], table, fieldMetadataLoaded = false; @@ -241,11 +241,20 @@ export default class TagEditorParam extends Component {

    {t`Default filter widget value`}

    this.setParameterAttribute("default", value)} className="AdminSelect p1 text-bold text-medium bordered border-medium rounded bg-white" diff --git a/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx b/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx index 07f1908f2c10b..5a4f3ac82105a 100644 --- a/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx +++ b/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.jsx @@ -3,6 +3,7 @@ import PropTypes from "prop-types"; import { t } from "ttag"; import cx from "classnames"; +import _ from "underscore"; import TagEditorParam from "./TagEditorParam"; import CardTagEditor from "./CardTagEditor"; @@ -71,6 +72,8 @@ export default class TagEditorSidebar extends React.Component { // The tag editor sidebar excludes snippets since they have a separate sidebar. const tags = query.templateTagsWithoutSnippets(); const database = query.database(); + const parameters = query.question().parameters(); + const parametersById = _.indexBy(parameters, "id"); let section; if (tags.length === 0) { @@ -100,6 +103,7 @@ export default class TagEditorSidebar extends React.Component { {section === "settings" ? ( { ).toEqual({ 123: 3, 456: 1 }); }); }); + + describe("doesOperatorExist", () => { + it("should return a boolean indicating existence of operator with given name", () => { + expect(doesOperatorExist("foo")).toBe(false); + expect(doesOperatorExist("contains")).toBe(true); + expect(doesOperatorExist("between")).toBe(true); + }); + }); + + describe("isEqualsOperator", () => { + it("given operator metadata object", () => { + it("should evaluate whether it is an equals operator", () => { + expect(isEqualsOperator()).toBe(false); + expect(isEqualsOperator({ name: "foo" })).toBe(false); + expect(isEqualsOperator({ name: "=" })).toBe(true); + }); + }); + }); + + describe("getOperatorByTypeAndName", () => { + it("should return undefined if operator does not exist", () => { + expect(getOperatorByTypeAndName("FOO", "=")).toBe(undefined); + expect(getOperatorByTypeAndName(NUMBER, "contains")).toBe(undefined); + }); + + it("should return a metadata object for specific operator type/name", () => { + expect(getOperatorByTypeAndName(NUMBER, "between")).toEqual({ + name: "between", + numFields: 2, + validArgumentsFilters: [expect.any(Function), expect.any(Function)], + verboseName: "Between", + }); + }); + }); }); diff --git a/frontend/test/metabase/meta/Card.unit.spec.js b/frontend/test/metabase/meta/Card.unit.spec.js index 48b3f021904a2..84bac489393ff 100644 --- a/frontend/test/metabase/meta/Card.unit.spec.js +++ b/frontend/test/metabase/meta/Card.unit.spec.js @@ -28,8 +28,8 @@ describe("metabase/meta/Card", () => { }, { id: 2, - slug: "param_number", - type: "category", + slug: "param_operator", + type: "category/starts-with", }, { id: 3, @@ -41,6 +41,11 @@ describe("metabase/meta/Card", () => { slug: "param_fk", type: "date/month", }, + { + id: 5, + slug: "param_number", + type: "number/=", + }, ]; describe("with SQL card", () => { @@ -116,6 +121,11 @@ describe("metabase/meta/Card", () => { parameter_id: 4, target: ["dimension", ["field", 5, { "source-field": 4 }]], }, + { + card_id: 1, + parameter_id: 5, + target: ["dimension", ["field", 2, null]], + }, ]; it("should return question URL with no parameters", () => { const url = Card.questionUrlWithParameters(card, metadata, []); @@ -167,12 +177,13 @@ describe("metabase/meta/Card", () => { ), }); }); + it("should return question URL with number MBQL filter added", () => { const url = Card.questionUrlWithParameters( card, metadata, parameters, - { "2": 123 }, + { "5": 123 }, parameterMappings, ); expect(parseUrl(url)).toEqual({ @@ -185,6 +196,7 @@ describe("metabase/meta/Card", () => { ), }); }); + it("should return question URL with date MBQL filter added", () => { const url = Card.questionUrlWithParameters( card, diff --git a/frontend/test/metabase/meta/Parameter.unit.spec.js b/frontend/test/metabase/meta/Parameter.unit.spec.js index 9d043b2e3f3e7..6c8edc8b00e5b 100644 --- a/frontend/test/metabase/meta/Parameter.unit.spec.js +++ b/frontend/test/metabase/meta/Parameter.unit.spec.js @@ -1,6 +1,11 @@ import { dateParameterValueToMBQL, stringParameterValueToMBQL, + numberParameterValueToMBQL, + parameterOptionsForField, + getParametersBySlug, + mapUIParameterToQueryParameter, + deriveFieldOperatorFromParameter, } from "metabase/meta/Parameter"; describe("metabase/meta/Parameter", () => { @@ -95,18 +100,202 @@ describe("metabase/meta/Parameter", () => { describe("stringParameterValueToMBQL", () => { describe("when given an array parameter value", () => { it("should flatten the array parameter values", () => { - expect(stringParameterValueToMBQL(["1", "2"], null)).toEqual([ - "=", - null, - "1", - "2", - ]); + expect( + stringParameterValueToMBQL( + { type: "category/=", value: ["1", "2"] }, + null, + ), + ).toEqual(["=", null, "1", "2"]); }); }); describe("when given a string parameter value", () => { it("should return the correct MBQL", () => { - expect(stringParameterValueToMBQL("1", null)).toEqual(["=", null, "1"]); + expect( + stringParameterValueToMBQL( + { type: "category/starts-with", value: "1" }, + null, + ), + ).toEqual(["starts-with", null, "1"]); + }); + }); + + it("should default the operator to `=`", () => { + expect( + stringParameterValueToMBQL( + { type: "category", value: ["1", "2"] }, + null, + ), + ).toEqual(["=", null, "1", "2"]); + + expect( + stringParameterValueToMBQL( + { type: "location/city", value: ["1", "2"] }, + null, + ), + ).toEqual(["=", null, "1", "2"]); + }); + }); + + describe("numberParameterValueToMBQL", () => { + describe("when given an array parameter value", () => { + it("should flatten the array parameter values", () => { + expect( + numberParameterValueToMBQL( + { type: "number/between", value: [1, 2] }, + null, + ), + ).toEqual(["between", null, 1, 2]); + }); + }); + + describe("when given a string parameter value", () => { + it("should parse the parameter value as a float", () => { + expect( + numberParameterValueToMBQL({ type: "number/=", value: "1.1" }, null), + ).toEqual(["=", null, 1.1]); + }); + }); + }); + + describe("parameterOptionsForField", () => { + const field = { + isDate: () => false, + isID: () => false, + isCategory: () => false, + isCity: () => false, + isState: () => false, + isZipCode: () => false, + isCountry: () => false, + isNumber: () => false, + }; + it("should relevantly typed options for date field", () => { + const dateField = { + ...field, + isDate: () => true, + }; + const availableOptions = parameterOptionsForField(dateField); + expect( + availableOptions.length > 0 && + availableOptions.every(option => option.type.startsWith("date")), + ).toBe(true); + }); + + it("should relevantly typed options for location field", () => { + const countryField = { + ...field, + isCountry: () => true, + }; + const availableOptions = parameterOptionsForField(countryField); + expect( + availableOptions.length > 0 && + availableOptions.every(option => option.type.startsWith("location")), + ).toBe(true); + }); + }); + + describe("getParameterBySlug", () => { + it("should return an object mapping slug to parameter value", () => { + const parameters = [ + { id: "foo", slug: "bar" }, + { id: "aaa", slug: "bbb" }, + { id: "cat", slug: "dog" }, + ]; + const parameterValuesById = { + foo: 123, + aaa: "ccc", + something: true, + }; + expect(getParametersBySlug(parameters, parameterValuesById)).toEqual({ + bar: 123, + bbb: "ccc", + }); + }); + }); + + describe("mapParameterTypeToFieldType", () => { + it("should return the proper parameter type of location/category parameters", () => { + expect(mapUIParameterToQueryParameter("category", "foo", "bar")).toEqual({ + type: "string/=", + value: ["foo"], + target: "bar", + }); + expect( + mapUIParameterToQueryParameter("category/starts-with", ["foo"], "bar"), + ).toEqual({ + type: "string/starts-with", + value: ["foo"], + target: "bar", + }); + expect( + mapUIParameterToQueryParameter("location/city", "foo", "bar"), + ).toEqual({ + type: "string/=", + value: ["foo"], + target: "bar", + }); + expect( + mapUIParameterToQueryParameter("location/contains", ["foo"], "bar"), + ).toEqual({ + type: "string/contains", + value: ["foo"], + target: "bar", + }); + }); + + it("should return given type when not a location/category option", () => { + expect(mapUIParameterToQueryParameter("foo/bar", "foo", "bar")).toEqual({ + type: "foo/bar", + value: "foo", + target: "bar", + }); + expect( + mapUIParameterToQueryParameter("date/single", "foo", "bar"), + ).toEqual({ + type: "date/single", + value: "foo", + target: "bar", + }); + }); + + it("should wrap number values in an array", () => { + expect(mapUIParameterToQueryParameter("number/=", [123], "bar")).toEqual({ + type: "number/=", + value: [123], + target: "bar", + }); + + expect(mapUIParameterToQueryParameter("number/=", 123, "bar")).toEqual({ + type: "number/=", + value: [123], + target: "bar", + }); + }); + }); + + describe("deriveFieldOperatorFromParameter", () => { + describe("when parameter is associated with an operator", () => { + it("should return relevant operator object", () => { + const operator1 = deriveFieldOperatorFromParameter({ + type: "location/city", + }); + const operator2 = deriveFieldOperatorFromParameter({ + type: "category/contains", + }); + const operator3 = deriveFieldOperatorFromParameter({ + type: "number/between", + }); + expect(operator1.name).toEqual("="); + expect(operator2.name).toEqual("contains"); + expect(operator3.name).toEqual("between"); + }); + }); + + describe("when parameter is NOT associated with an operator", () => { + it("should return undefined", () => { + expect(deriveFieldOperatorFromParameter({ type: "date/single" })).toBe( + undefined, + ); }); }); }); diff --git a/frontend/test/metabase/scenarios/dashboard/chained-filters.cy.spec.js b/frontend/test/metabase/scenarios/dashboard/chained-filters.cy.spec.js index 24bdb7d7dd832..89dfd50984316 100644 --- a/frontend/test/metabase/scenarios/dashboard/chained-filters.cy.spec.js +++ b/frontend/test/metabase/scenarios/dashboard/chained-filters.cy.spec.js @@ -124,7 +124,7 @@ describe("scenarios > dashboard > chained filter", () => { cy.icon("filter").click(); popover().within(() => { cy.findByText("Location").click(); - cy.findByText("State").click(); + cy.findByText("Dropdown").click(); }); // connect that to people.state @@ -142,7 +142,7 @@ describe("scenarios > dashboard > chained filter", () => { cy.findByText("add another dashboard filter").click(); popover().within(() => { cy.findByText("Location").click(); - cy.findByText("City").click(); + cy.findByText("Starts with").click(); }); // connect that to person.city @@ -160,14 +160,14 @@ describe("scenarios > dashboard > chained filter", () => { .parent() .within(() => { // turn on the toggle - cy.findByText("State") + cy.findByText("Location") .parent() .within(() => { cy.get("a").click(); }); // open up the list of linked columns - cy.findByText("State").click(); + cy.findByText("Location").click(); // It's hard to assert on the "table.column" pairs. // We just assert that the headers are there to know that something appeared. cy.findByText("Filtering column"); @@ -179,12 +179,12 @@ describe("scenarios > dashboard > chained filter", () => { // now test that it worked! // Select Alaska as a state. We should see Anchorage as a option but not Anacoco - cy.findByText("State").click(); + cy.findByText("Location").click(); popover().within(() => { cy.findByText("AK").click(); cy.findByText("Add filter").click(); }); - cy.findByText("City").click(); + cy.findByText("Location starts with").click(); popover().within(() => { cy.findByPlaceholderText( has_field_values === "search" ? "Search by City" : "Search the list", diff --git a/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js b/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js index d63f5cd9b0be3..e08a927e45f8c 100644 --- a/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js +++ b/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js @@ -71,7 +71,7 @@ describe("scenarios > dashboard", () => { // Adding location/state doesn't make much sense for this case, // but we're testing just that the filter is added to the dashboard cy.findByText("Location").click(); - cy.findByText("State").click(); + cy.findByText("Dropdown").click(); cy.findByText("Select…").click(); popover().within(() => { @@ -86,7 +86,7 @@ describe("scenarios > dashboard", () => { cy.log("Assert that the selected filter is present in the dashboard"); cy.icon("location"); - cy.findByText("State"); + cy.findByText("Location"); }); it("should add a question", () => { @@ -173,6 +173,7 @@ describe("scenarios > dashboard", () => { cy.icon("filter").click(); popover().within(() => { cy.findByText("Other Categories").click(); + cy.findByText("Starts with").click(); }); // and connect it to the card selectDashboardFilter(cy.get(".DashCard"), "Category"); diff --git a/frontend/test/metabase/scenarios/dashboard/dashboard_data_permissions.cy.spec.js b/frontend/test/metabase/scenarios/dashboard/dashboard_data_permissions.cy.spec.js index 326703c465ba3..c72529869f9ac 100644 --- a/frontend/test/metabase/scenarios/dashboard/dashboard_data_permissions.cy.spec.js +++ b/frontend/test/metabase/scenarios/dashboard/dashboard_data_permissions.cy.spec.js @@ -14,7 +14,7 @@ function filterDashboard(suggests = true) { expect(xhr.status).to.equal(403); }); } - cy.contains("Add filter").click(); + cy.contains("Add filter").click({ force: true }); cy.contains("Aerodynamic Bronze Hat"); cy.contains(/Rows \d-\d of 96/); } @@ -34,6 +34,10 @@ describe("support > permissions (metabase#8472)", () => { .contains("Other Categories") .click(); + popover() + .contains("Dropdown") + .click(); + // Filter the first card by product category selectDashboardFilter(cy.get(".DashCard").first(), "Title"); diff --git a/frontend/test/metabase/scenarios/dashboard/embed.cy.spec.js b/frontend/test/metabase/scenarios/dashboard/embed.cy.spec.js index 89e46145aa6ee..98c874033dd49 100644 --- a/frontend/test/metabase/scenarios/dashboard/embed.cy.spec.js +++ b/frontend/test/metabase/scenarios/dashboard/embed.cy.spec.js @@ -90,7 +90,10 @@ var iframeUrl = METABASE_SITE_URL + "/embed/dashboard/" + token + "#bordered=tru .find("textarea") .type("text text text"); cy.icon("filter").click(); - popover().within(() => cy.findByText("Other Categories").click()); + popover().within(() => { + cy.findByText("Other Categories").click(); + cy.findByText("Dropdown").click(); + }); cy.findByText("Save").click(); // confirm text box and filter are still there diff --git a/frontend/test/metabase/scenarios/dashboard/parameters.cy.spec.js b/frontend/test/metabase/scenarios/dashboard/parameters.cy.spec.js index 931a2cd9e42c5..c5a47704d4428 100644 --- a/frontend/test/metabase/scenarios/dashboard/parameters.cy.spec.js +++ b/frontend/test/metabase/scenarios/dashboard/parameters.cy.spec.js @@ -15,7 +15,7 @@ describe("scenarios > dashboard > parameters", () => { cy.icon("pencil").click(); cy.icon("filter").click(); cy.findByText("Location").click(); - cy.findByText("City").click(); + cy.findByText("Dropdown").click(); // Link that filter to the card cy.findByText("Select…").click(); @@ -61,6 +61,7 @@ describe("scenarios > dashboard > parameters", () => { // add a category filter cy.icon("filter").click(); cy.contains("Other Categories").click(); + cy.findByText("Starts with").click(); // connect it to people.name and product.category // (this doesn't make sense to do, but it illustrates the feature) @@ -104,6 +105,51 @@ describe("scenarios > dashboard > parameters", () => { .contains("4,939"); }); + it("should query with a 2 argument parameter", () => { + cy.createDashboard("my dash"); + + cy.visit("/collection/root"); + cy.findByText("my dash").click(); + + // add a question + cy.icon("pencil").click(); + addQuestion("Orders, Count"); + + // add a Number - Between filter + cy.icon("filter").click(); + cy.contains("Number").click(); + cy.findByText("Between").click(); + + // map the parameter to the Rating field + selectFilter(cy.get(".DashCard"), "Rating"); + + // finish editing filter and save dashboard + cy.contains("Save").click(); + + // wait for saving to finish + cy.contains("You're editing this dashboard.").should("not.exist"); + + // populate the filter inputs + cy.contains("Between").click(); + popover() + .find("input") + .first() + .type("3"); + + popover() + .find("input") + .last() + .type("4"); + + popover() + .contains("Add filter") + .click(); + + // There should be 8849 orders with a rating >= 3 && <= 4 + cy.get(".DashCard").contains("8,849"); + cy.url().should("include", "between=3&between=4"); + }); + it("should remove previously deleted dashboard parameter from URL (metabase#10829)", () => { // Mirrored issue in metabase-enterprise#275 @@ -114,31 +160,44 @@ describe("scenarios > dashboard > parameters", () => { cy.icon("pencil").click(); cy.icon("filter").click(); cy.contains("Other Categories").click(); + cy.contains("Ends with").click(); + + // map the parameter to the Category field + selectFilter(cy.get(".DashCard"), "Category"); + cy.findByText("Save").click(); - // Give value to the filter - cy.findByPlaceholderText("Category") - .click() - .type("Gizmo{enter}"); + // wait for saving to finish + cy.contains("You're editing this dashboard.").should("not.exist"); + + // populate the filter input + cy.findByText("Category ends with").click(); + popover() + .find("input") + .type("zmo"); + + popover() + .contains("Add filter") + .click(); cy.log( "**URL is updated correctly with the given parameter at this point**", ); - cy.url().should("include", "category=Gizmo"); + cy.url().should("include", "category_ends_with=zmo"); // Remove filter name cy.icon("pencil").click(); cy.get(".Dashboard") .find(".Icon-gear") .click(); - cy.findByDisplayValue("Category") + cy.findByDisplayValue("Category ends with") .click() .clear(); cy.findByText("Save").click(); cy.findByText("You're editing this dashboard.").should("not.exist"); cy.log("Filter name should be 'unnamed' and the value cleared"); - cy.findByPlaceholderText(/unnamed/i); + cy.findByText(/unnamed/i); cy.log("URL should reset"); cy.location("pathname").should("eq", "/dashboard/1"); diff --git a/modules/drivers/mongo/src/metabase/driver/mongo/parameters.clj b/modules/drivers/mongo/src/metabase/driver/mongo/parameters.clj index 59db96499a33d..12d4db9d1a8fe 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo/parameters.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo/parameters.clj @@ -1,14 +1,18 @@ (ns metabase.driver.mongo.parameters - (:require [clojure.string :as str] + (:require [cheshire.core :as json] + [clojure.string :as str] [clojure.tools.logging :as log] [clojure.walk :as walk] [java-time :as t] [metabase.driver.common.parameters :as params] [metabase.driver.common.parameters.dates :as date-params] + [metabase.driver.common.parameters.operators :as ops] [metabase.driver.common.parameters.parse :as parse] [metabase.driver.common.parameters.values :as values] [metabase.driver.mongo.query-processor :as mongo.qp] + [metabase.mbql.util :as mbql.u] [metabase.query-processor.error-type :as error-type] + [metabase.query-processor.middleware.wrap-value-literals :as wrap-value-literals] [metabase.query-processor.store :as qp.store] [metabase.util :as u] [metabase.util.date-2 :as u.date] @@ -55,14 +59,18 @@ :else (pr-str x))) -(defn- field->name [field] +(defn- field->name + ([field] (field->name field true)) ;; store parent Field(s) if needed, since `mongo.qp/field->name` attempts to look them up using the QP store - (letfn [(store-parent-field! [{parent-id :parent_id}] - (when parent-id - (qp.store/fetch-and-store-fields! #{parent-id}) - (store-parent-field! (qp.store/field parent-id))))] - (store-parent-field! field)) - (pr-str (mongo.qp/field->name field "."))) + ([field pr?] + (letfn [(store-parent-field! [{parent-id :parent_id}] + (when parent-id + (qp.store/fetch-and-store-fields! #{parent-id}) + (store-parent-field! (qp.store/field parent-id))))] + (store-parent-field! field)) + ;; for native parameters we serialize and don't need the extra pr + (cond-> (mongo.qp/field->name field ".") + pr? pr-str))) (defn- substitute-one-field-filter-date-range [{field :field, {param-type :type, value :value} :value}] (let [{:keys [start end]} (date-params/date-string->range value {:inclusive-end? false}) @@ -108,6 +116,22 @@ (params/FieldFilter? v) (let [no-value? (= (:value v) params/no-value)] (cond + (ops/operator? (get-in v [:value :type])) + (let [param (:value v) + compiled-clause (-> (assoc param + :target + [:template-tag + [:field (field->name (:field v) false) + {:base-type (get-in v [:field :base_type])}]]) + ops/to-clause + ;; desugar only impacts :does-not-contain -> [:not [:contains ... but it prevents + ;; an optimization of [:= 'field 1 2 3] -> [:in 'field [1 2 3]] since that + ;; desugars to [:or [:= 'field 1] ...]. + mbql.u/desugar-filter-clause + wrap-value-literals/wrap-value-literals-in-mbql + mongo.qp/compile-filter + json/generate-string)] + [(conj acc compiled-clause) missing]) ;; no-value field filters inside optional clauses are ignored and omitted entirely (and no-value? in-optional?) [acc (conj missing k)] ;; otherwise replace it with a {} which is the $match equivalent of 1 = 1, i.e. always true 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 fb786534f9302..62958db89ef29 100644 --- a/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj +++ b/modules/drivers/mongo/src/metabase/driver/mongo/query_processor.clj @@ -289,7 +289,11 @@ (defmethod ->rvalue ::not [[_ value]] {$not (->rvalue value)}) -(defmulti ^:private compile-filter mbql.u/dispatch-by-clause-name-or-class) +(defmulti compile-filter + "Compile an mbql filter clause to datastructures suitable to query mongo. Note this is not the whole query but just + compiling the \"where\" clause equivalent." + {:arglists '([clause])} + mbql.u/dispatch-by-clause-name-or-class) (def ^:private ^:dynamic *top-level-filter?* "Whether we are compiling a top-level filter clause. This means we can generate somewhat simpler `$match` clauses that @@ -306,8 +310,10 @@ (if (mbql.u/is-clause? ::not value) {$not (str-match-pattern options prefix (second value) suffix)} (let [case-sensitive? (get options :case-sensitive true)] - (re-pattern (str (when-not case-sensitive? "(?i)") prefix (->rvalue value) suffix))))) + {$regex (str (when-not case-sensitive? "(?i)") prefix (->rvalue value) suffix)}))) +;; these are changed to {field {$regex "regex"}} instead of {field #regex} for serialization purposes. When doing +;; native query substitution we need a string and the explicit regex form is better there (defmethod compile-filter :contains [[_ field v opts]] {(->lvalue field) (str-match-pattern opts nil v nil)}) (defmethod compile-filter :starts-with [[_ field v opts]] {(->lvalue field) (str-match-pattern opts \^ v nil)}) (defmethod compile-filter :ends-with [[_ field v opts]] {(->lvalue field) (str-match-pattern opts nil v \$)}) diff --git a/modules/drivers/mongo/test/metabase/driver/mongo/parameters_test.clj b/modules/drivers/mongo/test/metabase/driver/mongo/parameters_test.clj index 328c78d368979..363970df8a9e4 100644 --- a/modules/drivers/mongo/test/metabase/driver/mongo/parameters_test.clj +++ b/modules/drivers/mongo/test/metabase/driver/mongo/parameters_test.clj @@ -1,6 +1,7 @@ (ns metabase.driver.mongo.parameters-test (:require [cheshire.core :as json] [cheshire.generate :as json.generate] + [clojure.set :as set] [clojure.string :as str] [clojure.test :refer :all] [java-time :as t] @@ -28,8 +29,14 @@ (defn- optional [& xs] (common.params/->Optional xs)) -(defn- field-filter [field-name value-type value] - (common.params/->FieldFilter {:name (name field-name)} {:type value-type, :value value})) +(defn- field-filter + ([field-name value-type value] + (field-filter field-name nil value-type value)) + ([field-name base-type value-type value] + (common.params/->FieldFilter (cond-> {:name (name field-name)} + base-type + (assoc :base_type base-type)) + {:type value-type, :value value}))) (defn- comma-separated-numbers [nums] (common.params/->CommaSeparatedNumbers nums)) @@ -122,6 +129,9 @@ (defn- ISODate [s] (bson-fn-call :ISODate s)) +(defn- strip [s] + (str/replace s #"\s" "")) + (deftest field-filter-test (testing "Date ranges" (mt/with-clock #t "2019-12-13T12:00:00.000Z[UTC]" @@ -152,7 +162,62 @@ ["[{$match: " (param :date) "}]"])))) (testing "parameter not supplied" (is (= (to-bson [{:$match {}}]) - (substitute {:date (common.params/->FieldFilter {:name "date"} common.params/no-value)} ["[{$match: " (param :date) "}]"]))))) + (substitute {:date (common.params/->FieldFilter {:name "date"} common.params/no-value)} ["[{$match: " (param :date) "}]"])))) + (testing "operators" + (testing "string" + (doseq [[operator form input] [[:string/starts-with {"$regex" "^foo"} ["foo"]] + [:string/ends-with {"$regex" "foo$"} ["foo"]] + [:string/contains {"$regex" "foo"} ["foo"]] + [:string/does-not-contain {"$not" {"$regex" "foo"}} ["foo"]] + [:string/= {"$eq" "foo"} ["foo"]]]] + (testing operator + (is (= (strip (to-bson [{:$match {"description" form}}])) + (strip + (substitute {:desc (field-filter "description" :type/Text operator input)} + ["[{$match: " (param :desc) "}]"]))))))) + (testing "numeric" + (doseq [[operator form input] [[:number/<= {"price" {"$lte" 42}} [42]] + [:number/>= {"price" {"$gte" 42}} [42]] + [:number/!= {"price" {"$ne" 42}} [42]] + [:number/= {"price" {"$eq" 42}} [42]] + ;; i don't understand the $ on price here + [:number/between {"$and" [{"$expr" {"$gte" ["$price" 42]}} + {"$expr" {"$lte" ["$price" 142]}}]} [42 142]]]] + (testing operator + (is (= (strip (to-bson [{:$match form}])) + (strip + (substitute {:price (field-filter "price" :type/Integer operator input)} + ["[{$match: " (param :price) "}]"]))))))) + (testing "variadic operators" + (testing :string/= + ;; this could be optimized to {:description {:in ["foo" "bar"}}? + (is (= (strip (to-bson [{:$match {"$or" [{"$expr" {"$eq" ["$description" "foo"]}} + {"$expr" {"$eq" ["$description" "bar"]}}]}}])) + (strip + (substitute {:desc (field-filter "description" :type/Text :string/= ["foo" "bar"])} + ["[{$match: " (param :desc) "}]"]))))) + (testing :string/!= + ;; this could be optimized to {:description {:in ["foo" "bar"}}? one thing is that we pass it through the + ;; desugar middleware that does this [:= 1 2] -> [:or [:= 1] [:= 2]] which makes for more complicated (or just + ;; verbose?) query where. perhaps we can introduce some notion of what is sugar and what isn't. I bet the line + ;; between what the desugar "optimizes" and what the query processors optimize might be a bit blurry + (is (= (strip (to-bson [{:$match {"$and" [{"$expr" {"$ne" ["$description" "foo"]}} + {"$expr" {"$ne" ["$description" "bar"]}}]}}])) + (strip + (substitute {:desc (field-filter "description" :type/Text :string/!= ["foo" "bar"])} + ["[{$match: " (param :desc) "}]"]))))) + (testing :number/= + (is (= (strip (to-bson [{:$match {"$or" [{"$expr" {"$eq" ["$price" 1]}} + {"$expr" {"$eq" ["$price" 2]}}]}}])) + (strip + (substitute {:price (field-filter "price" :type/Integer :number/= [1 2])} + ["[{$match: " (param :price) "}]"]))))) + (testing :number/!= + (is (= (strip (to-bson [{:$match {"$and" [{"$expr" {"$ne" ["$price" 1]}} + {"$expr" {"$ne" ["$price" 2]}}]}}])) + (strip + (substitute {:price (field-filter "price" :type/Integer :number/!= [1 2])} + ["[{$match: " (param :price) "}]"])))))))) (defn- json-raw "Wrap a string so it will be spliced directly into resulting JSON as-is. Analogous to HoneySQL `raw`." @@ -235,4 +300,83 @@ :dimension $tips.source.username}}} :parameters [{:type :text :target [:dimension [:template-tag "username"]] - :value "tupac"}]})))))))))) + :value "tupac"}]}))))))) + (testing "operators" + (testing "string" + (doseq [[regex operator] [["tu" :string/starts-with] + ["pac" :string/ends-with] + ["tupac" :string/=] + ["upa" :string/contains]]] + (mt/dataset geographical-tips + (is (= [[5 "tupac"]] + (mt/rows + (qp/process-query + (mt/query tips + {:type :native + :native {:query (json/generate-string + [{:$match (json-raw "{{username}}")} + {:$sort {:_id 1}} + {:$project {"username" "$source.username"}} + {:$limit 1}]) + :collection "tips" + :template-tags {"username" {:name "username" + :display-name "Username" + :type :dimension + :dimension $tips.source.username}}} + :parameters [{:type operator + :target [:dimension [:template-tag "username"]] + :value [regex]}]})))))))) + (testing "numeric" + (doseq [[input operator pred] [[[1] :number/<= #(<= % 1)] + [[2] :number/>= #(>= % 2)] + [[2] :number/= #(= % 2)] + [[2] :number/!= #(not= % 2)] + [[1 3] :number/between #(<= 1 % 3)]]] + (is (every? (comp pred second) ;; [id price] + (mt/rows + (qp/process-query + (mt/query venues + {:type :native + :native {:query (json/generate-string + [{:$match (json-raw "{{price}}")} + {:$project {"price" "$price"}} + {:$sort {:_id 1}} + {:$limit 10}]) + :collection "venues" + :template-tags {"price" {:name "price" + :display-name "Price" + :type :dimension + :dimension $price}}} + :parameters [{:type operator + :target [:dimension [:template-tag "price"]] + :value input}]}))))))) + (testing "variadic operators" + (let [run-query! (fn [operator] + (mt/dataset geographical-tips + (mt/rows + (qp/process-query + (mt/query tips + {:type :native + :native {:query (json/generate-string + [{:$match (json-raw "{{username}}")} + {:$sort {:_id 1}} + {:$project {"username" "$source.username"}} + {:$limit 20}]) + :collection "tips" + :template-tags {"username" {:name "username" + :display-name "Username" + :type :dimension + :dimension $tips.source.username}}} + :parameters [{:type operator + :target [:dimension [:template-tag "username"]] + :value ["bob" "tupac"]}]})))))] + (is (= #{"bob" "tupac"} + (into #{} (map second) + (run-query! :string/=)))) + (is (= #{} + (set/intersection + #{"bob" "tupac"} + ;; most of these are nil as most records don't have a username. not equal is a bit ambiguous in + ;; mongo. maybe they might want present but not equal semantics + (into #{} (map second) + (run-query! :string/!=))))))))))) diff --git a/src/metabase/driver/common/parameters/operators.clj b/src/metabase/driver/common/parameters/operators.clj new file mode 100644 index 0000000000000..65d0ab4724c18 --- /dev/null +++ b/src/metabase/driver/common/parameters/operators.clj @@ -0,0 +1,79 @@ +(ns metabase.driver.common.parameters.operators + "This namespace handles parameters that are operators. + + {:type :number/between + :target [:dimension + [:field + 26 + {:source-field 5}]] + :value [3 5]}" + (:require [metabase.mbql.schema :as mbql.s] + [metabase.models.params :as params] + [metabase.query-processor.error-type :as qp.error-type] + [schema.core :as s])) + +(def ^:private unary {:string/starts-with :starts-with + :string/ends-with :ends-with + :string/contains :contains + :string/does-not-contain :does-not-contain + :number/>= :>= + :number/<= :<=}) + +(def ^:private binary {:number/between :between}) + +(def ^:private variadic {:string/= := + :string/!= :!= + :number/= := + :number/!= :!=}) + +(def ^:private all-ops (into #{} (mapcat keys [unary binary variadic]))) + +(s/defn operator? :- s/Bool + "Returns whether param-type is an \"operator\" type." + [param-type] + (contains? all-ops param-type)) + +(s/defn ^:private verify-type-and-arity + [field param-type param-value] + (letfn [(maybe-arity-error [n] + (when (not= n (count param-value)) + (throw (ex-info (format "Operations Invalid arity: expected %s but received %s" + n (count param-value)) + {:param-type param-type + :param-value param-value + :field-id (second field) + :type qp.error-type/invalid-parameter}))))] + (cond (contains? unary param-type) (maybe-arity-error 1) + (contains? binary param-type) (maybe-arity-error 2) + (contains? variadic param-type) (when-not (seq param-value) + (throw (ex-info (format "No values provided for operator: %s" param-type) + {:param-type param-type + :param-value param-value + :field-id (second field) + :type qp.error-type/invalid-parameter}))) + :else (throw (ex-info (format "Unrecognized operation: %s" param-type) + {:param-type param-type + :param-value param-value + :field-id (second field) + :type qp.error-type/invalid-parameter}))))) + +(s/defn to-clause :- mbql.s/Filter + "Convert an operator style parameter into an mbql clause. Will also do arity checks and throws an ex-info with + `:type qp.error-type/invalid-parameter` if arity is incorrect." + [{param-type :type [a b :as param-value] :value [_ field :as _target] :target :as param}] + (verify-type-and-arity field param-type param-value) + (let [field' (params/wrap-field-id-if-needed field)] + (cond (contains? binary param-type) + [(binary param-type) field' a b] + + (contains? unary param-type) + [(unary param-type) field' a] + + (contains? variadic param-type) + (into [(variadic param-type) field'] param-value) + + :else (throw (ex-info (format "Unrecognized operator: %s" param-type) + {:param-type param-type + :param-value param-value + :field-id (second field) + :type qp.error-type/invalid-parameter}))))) diff --git a/src/metabase/driver/sql/parameters/substitution.clj b/src/metabase/driver/sql/parameters/substitution.clj index 5d2e4a95b2939..afb927f8cb1a1 100644 --- a/src/metabase/driver/sql/parameters/substitution.clj +++ b/src/metabase/driver/sql/parameters/substitution.clj @@ -11,8 +11,11 @@ [metabase.driver :as driver] [metabase.driver.common.parameters :as i] [metabase.driver.common.parameters.dates :as date-params] + [metabase.driver.common.parameters.operators :as ops] [metabase.driver.sql.query-processor :as sql.qp] + [metabase.mbql.util :as mbql.u] [metabase.query-processor.error-type :as qp.error-type] + [metabase.query-processor.middleware.wrap-value-literals :as wrap-value-literals] [metabase.query-processor.timezone :as qp.timezone] [metabase.util.date-2 :as u.date] [metabase.util.i18n :refer [tru]] @@ -210,19 +213,6 @@ (-> (->replacement-snippet-info driver (i/map->MultipleValues {:values values})) (update :replacement-snippet (partial format "IN (%s)")))) -(s/defn ^:private field-filter->replacement-snippet-info :- ParamSnippetInfo - "Return `[replacement-snippet & prepared-statement-args]` appropriate for a field filter parameter." - [driver {param-type :type, value :value} :- i/ParamValue] - (cond - ;; convert date ranges to DateRange record types - (date-params/date-range-type? param-type) (date-range-field-filter->replacement-snippet-info driver value) - ;; convert all other dates to `= ` - (date-params/date-type? param-type) (field-filter->equals-clause-sql driver (i/map->Date {:s value})) - ;; for sequences of multiple values we want to generate an `IN (...)` clause - (sequential? value) (field-filter-multiple-values->in-clause-sql driver value) - ;; convert everything else to `= ` - :else (field-filter->equals-clause-sql driver value))) - (s/defn ^:private honeysql->replacement-snippet-info :- ParamSnippetInfo "Convert `hsql-form` to a replacement snippet info map by passing it to HoneySQL's `format` function." [driver hsql-form] @@ -243,8 +233,39 @@ (sql.qp/date driver :day identifier) identifier))))) +(s/defn ^:private field-filter->replacement-snippet-info :- ParamSnippetInfo + "Return `[replacement-snippet & prepared-statement-args]` appropriate for a field filter parameter." + [driver {{param-type :type, value :value :as params} :value field :field :as _field-filter}] + (let [prepend-field + (fn [x] + (update x :replacement-snippet + (partial str (field->identifier driver field param-type) " ")))] + (cond + (ops/operator? param-type) + (let [[snippet & args] + (->> (ops/to-clause (assoc params :target + [:template-tag [:field (:name field) + {:base-type (:base_type field)}]])) + mbql.u/desugar-filter-clause + wrap-value-literals/wrap-value-literals-in-mbql + (sql.qp/->honeysql driver/*driver*) + hsql/format-predicate)] + {:replacement-snippet snippet, :prepared-statement-args (vec args)}) + ;; convert date ranges to DateRange record types + (date-params/date-range-type? param-type) (prepend-field + (date-range-field-filter->replacement-snippet-info driver value)) + ;; convert all other dates to `= ` + (date-params/date-type? param-type) (prepend-field + (field-filter->equals-clause-sql driver (i/map->Date {:s value}))) + ;; for sequences of multiple values we want to generate an `IN (...)` clause + (sequential? value) (prepend-field + (field-filter-multiple-values->in-clause-sql driver value)) + ;; convert everything else to `= ` + :else (prepend-field + (field-filter->equals-clause-sql driver value))))) + (defmethod ->replacement-snippet-info [:sql FieldFilter] - [driver {:keys [field value], :as field-filter}] + [driver {:keys [value], :as field-filter}] (cond ;; otherwise if the value isn't present just put in something that will always be true, such as `1` (e.g. `WHERE 1 ;; = 1`). This is only used for field filters outside of optional clauses @@ -258,8 +279,7 @@ ;; otherwise convert single value to SQL. ;; Convert the value to a replacement snippet info map and then tack on the field identifier to the front :else - (update (field-filter->replacement-snippet-info driver value) - :replacement-snippet (partial str (field->identifier driver field (:type value)) " ")))) + (field-filter->replacement-snippet-info driver field-filter))) ;;; ------------------------------------ Referenced Card replacement snippet info ------------------------------------ diff --git a/src/metabase/query_processor/middleware/parameters/mbql.clj b/src/metabase/query_processor/middleware/parameters/mbql.clj index e612038349f71..e5e364365d747 100644 --- a/src/metabase/query_processor/middleware/parameters/mbql.clj +++ b/src/metabase/query_processor/middleware/parameters/mbql.clj @@ -1,6 +1,7 @@ (ns metabase.query-processor.middleware.parameters.mbql "Code for handling parameter substitution in MBQL queries." (:require [metabase.driver.common.parameters.dates :as date-params] + [metabase.driver.common.parameters.operators :as ops] [metabase.mbql.schema :as mbql.s] [metabase.mbql.util :as mbql.u] [metabase.models.field :refer [Field]] @@ -8,6 +9,15 @@ [schema.core :as s] [toucan.db :as db])) +(s/defn ^:private to-numeric :- s/Num + "Returns either a double or a long. Possible to use the edn reader but we would then have to worry about biginters + or arbitrary maps/stuff being read. Error messages would be more confusing EOF while reading instead of a more + sensical number format exception." + [s] + (if (re-find #"\." s) + (Double/parseDouble s) + (Long/parseLong s))) + (s/defn ^:private parse-param-value-for-type "Convert `param-value` to a type appropriate for `param-type`. The frontend always passes parameters in as strings, which is what we want in most cases; for numbers, instead @@ -28,17 +38,14 @@ (not (string? param-value))) param-value - ;; if PARAM-VALUE contains a period then convert to a Double - (re-find #"\." param-value) - (Double/parseDouble param-value) - - ;; otherwise convert to a Long :else - (Long/parseLong param-value))) + (to-numeric param-value))) (s/defn ^:private build-filter-clause :- (s/maybe mbql.s/Filter) [{param-type :type, param-value :value, [_ field :as target] :target, :as param}] (cond + (ops/operator? param-type) + (ops/to-clause param) ;; multipe values. Recursively handle them all and glue them all together with an OR clause (sequential? param-value) (mbql.u/simplify-compound-filter diff --git a/src/metabase/query_processor/middleware/wrap_value_literals.clj b/src/metabase/query_processor/middleware/wrap_value_literals.clj index 8aacfe6d50aa1..ad0d5e20f74e4 100644 --- a/src/metabase/query_processor/middleware/wrap_value_literals.clj +++ b/src/metabase/query_processor/middleware/wrap_value_literals.clj @@ -103,7 +103,20 @@ (def ^:private raw-value? (complement mbql.u/mbql-clause?)) -(defn- wrap-value-literals-in-mbql [mbql] +(defn wrap-value-literals-in-mbql + "Given a normalized mbql query (important to desugar forms like `[:does-not-contain ...]` -> `[:not [:contains + ...]]`), walks over the clause and annotates literals with type information. + + eg: + + [:not [:contains [:field 13 {:base_type :type/Text}] \"foo\"]] + -> + [:not [:contains [:field 13 {:base_type :type/Text}] + [:value \"foo\" {:base_type :type/Text, + :semantic_type nil, + :database_type \"VARCHAR\", + :name \"description\"}]]]" + [mbql] (mbql.u/replace mbql [(clause :guard #{:= :!= :< :> :<= :>=}) field (x :guard raw-value?)] [clause field (add-type-info x (type-info field))] diff --git a/test/metabase/driver/common/parameters/operators_test.clj b/test/metabase/driver/common/parameters/operators_test.clj new file mode 100644 index 0000000000000..9bf17aadeb839 --- /dev/null +++ b/test/metabase/driver/common/parameters/operators_test.clj @@ -0,0 +1,65 @@ +(ns metabase.driver.common.parameters.operators-test + (:require [clojure.test :refer :all] + [metabase.driver.common.parameters.operators :as ops] + [metabase.query-processor.error-type :as qp.error-type] + [schema.core :as s])) + +(deftest to-clause-test + (testing "number operations" + (is (= (ops/to-clause {:type :number/= + :target [:dimension + [:field + 26 + {:source-field 5}]] + :value [3]}) + [:= [:field 26 {:source-field 5}] 3])) + (is (= (ops/to-clause {:type :number/between + :target [:dimension + [:field + 26 + {:source-field 5}]] + :value [3 9]}) + [:between [:field 26 {:source-field 5}] 3 9])) + (testing "equality is variadic" + (is (= [:= [:field 26 {:source-field 5}] 3 4 5] + (ops/to-clause {:type :number/= + :target [:dimension + [:field + 26 + {:source-field 5}]] + :value [3 4 5]}))))) + (testing "string operations" + (is (= (ops/to-clause {:type :string/starts-with + :target [:dimension + [:field + 26 + {:source-field 5}]] + :value ["foo"]}) + [:starts-with [:field 26 {:source-field 5}] "foo"])) + (is (= (ops/to-clause {:type :string/does-not-contain + :target [:dimension + [:field + 26 + {:source-field 5}]] + :value ["foo"]}) + [:does-not-contain [:field 26 {:source-field 5}] "foo"]))) + (testing "arity errors" + (letfn [(f [op values] + (try + (ops/to-clause {:type op + :target [:dimension + [:field + 26 + {:source-field 5}]] + :value values}) + (is false "Did not throw") + (catch Exception e + (ex-data e))))] + (doseq [[op values] [[:string/starts-with ["a" "b"]] + [:number/between [1]] + [:number/between [1 2 3]]]] + (is (schema= {:param-type (s/eq op) + :param-value (s/eq values) + :field-id s/Any + :type (s/eq qp.error-type/invalid-parameter)} + (f op values))))))) diff --git a/test/metabase/driver/sql/parameters/substitute_test.clj b/test/metabase/driver/sql/parameters/substitute_test.clj index dbf98ba597b38..04b9bd82964cb 100644 --- a/test/metabase/driver/sql/parameters/substitute_test.clj +++ b/test/metabase/driver/sql/parameters/substitute_test.clj @@ -12,7 +12,6 @@ [metabase.query-processor.middleware.parameters.native :as native] [metabase.query-processor.test-util :as qp.test-util] [metabase.test :as mt] - [metabase.test.data.datasets :as datasets] [metabase.util.schema :as su] [schema.core :as s])) @@ -106,7 +105,62 @@ (substitute query {"date" (date-field-filter-value)})))) (testing "param is missing — should be omitted entirely" (is (= ["select * from checkins" nil] - (substitute query {"date" (assoc (date-field-filter-value) :value i/no-value)})))))))) + (substitute query {"date" (assoc (date-field-filter-value) :value i/no-value)}))))))) + (testing "new operators" + (testing "string operators" + (let [query ["select * from venues where " (param "param")]] + (doseq [[operator {:keys [field value expected]}] + (partition-all + 2 + [:string/contains {:field :name + :value ["foo"] + :expected ["select * from venues where (NAME like ?)" + ["%foo%"]]} + :string/does-not-contain {:field :name + :value ["foo"] + :expected ["select * from venues where (NOT (NAME like ?) OR NAME IS NULL)" + ["%foo%"]]} + :string/starts-with {:field :name + :value ["foo"] + :expected ["select * from venues where (NAME like ?)" + ["foo%"]]} + :string/= {:field :name + :value ["foo"] + :expected ["select * from venues where NAME = ?" + ["foo"]]} + :string/= {:field :name + :value ["foo" "bar" "baz"] + :expected ["select * from venues where (NAME = ? OR NAME = ? OR NAME = ?)" ["foo" "bar" "baz"]]} + :string/!= {:field :name + :value ["foo" "bar"] + :expected ["select * from venues where ((NAME <> ? OR NAME IS NULL) AND (NAME <> ? OR NAME IS NULL))" + ["foo" "bar"]]} + :number/= {:field :price + :value [1] + :expected ["select * from venues where PRICE = 1" ()]} + :number/= {:field :price + :value [1 2 3] + :expected ["select * from venues where (PRICE = 1 OR PRICE = 2 OR PRICE = 3)" ()]} + :number/!= {:field :price + :value [1] + :expected ["select * from venues where (PRICE <> 1 OR PRICE IS NULL)" ()]} + :number/!= {:field :price + :value [1 2 3] + :expected [(str "select * from venues where ((PRICE <> 1 OR PRICE IS NULL) " + "AND (PRICE <> 2 OR PRICE IS NULL) AND (PRICE <> 3 OR PRICE IS NULL))") + ()]} + :number/>= {:field :price + :value [1] + :expected ["select * from venues where PRICE >= 1" ()]} + :number/between {:field :price + :value [1 3] + :expected ["select * from venues where PRICE BETWEEN 1 AND 3" ()]}])] + (testing operator + (is (= expected + (substitute query {"param" (i/map->FieldFilter + {:field (Field (mt/id :venues field)) + :value {:type operator + :value value}})}))))))))) ;;; -------------------------------------------- Referenced Card Queries --------------------------------------------- diff --git a/test/metabase/query_processor/middleware/parameters/mbql_test.clj b/test/metabase/query_processor/middleware/parameters/mbql_test.clj index 945c17202ef34..af3e53a33ce3c 100644 --- a/test/metabase/query_processor/middleware/parameters/mbql_test.clj +++ b/test/metabase/query_processor/middleware/parameters/mbql_test.clj @@ -136,6 +136,31 @@ :target $price :value "4"}]})))))))) +(deftest operations-e2e-test + (mt/test-drivers (params-test-drivers) + (testing "check that operations works correctly" + (let [f #(mt/formatted-rows [int] + (qp/process-query %))] + (testing "binary numeric" + (is (= [[78]] + (f (mt/query venues + {:query {:aggregation [[:count]]} + :parameters [{:name "price" + :type :number/between + :target $price + :value [2 5]}]}))))) + (testing "unary string" + (is (= [(case driver/*driver* + ;; no idea why this count is off... + (:mysql :sqlite :sqlserver) [12] + [11])] + (f (mt/query venues + {:query {:aggregation [[:count]]} + :parameters [{:name "name" + :type :string/starts-with + :target $name + :value ["B"]}]}))))))))) + (deftest basic-where-test (mt/test-drivers (params-test-drivers) (testing "test that we can inject a basic `WHERE field = value` type param" @@ -158,7 +183,17 @@ :parameters [{:name "price" :type :category :target $price - :value 4}]}))))))))) + :value 4}]})))))) + (testing "`:number/>=` param type" + (is (= [[78]] + (mt/formatted-rows [int] + (qp/process-query + (mt/query venues + {:query {:aggregation [[:count]]} + :parameters [{:name "price" + :type :number/>= + :target $price + :value [2]}]}))))))))) ;; Make sure that *multiple* values work. This feature was added in 0.28.0. You are now allowed to pass in an array of ;; parameter values instead of a single value, which should stick them together in a single MBQL `:=` clause, which @@ -191,6 +226,30 @@ :parameters [{:name "price" :type :category :target $price + :value [3 4]}]}))))))) + (testing "Make sure multiple values with operators works" + (let [query (mt/query venues + {:query {:aggregation [[:count]]} + :parameters [{:name "price" + :type :number/between + :target $price + :value [3 4]}]})] + (mt/test-drivers (params-test-drivers) + (is (= [[19]] + (mt/formatted-rows [int] + (qp/process-query query))))) + + (testing "Make sure correct query is generated" + (is (= {:query (str "SELECT count(*) AS \"count\" " + "FROM \"PUBLIC\".\"VENUES\" " + "WHERE \"PUBLIC\".\"VENUES\".\"PRICE\" BETWEEN 3 AND 4") + :params nil} + (qp/query->native + (mt/query venues + {:query {:aggregation [[:count]]} + :parameters [{:name "price" + :type :number/between + :target $price :value [3 4]}]})))))))) ;; try it with date params as well. Even though there's no way to do this in the frontend AFAIK there's no reason we @@ -242,7 +301,22 @@ {:query {:order-by [[:asc $id]]} :parameters [{:type :id :target [:dimension $category_id->categories.name] - :value ["BBQ"]}]})))))))) + :value ["BBQ"]}]})))))) + (testing "Operators work on fk" + (is (= [[31 "Bludso's BBQ" 5 33.8894 -118.207 2] + [32 "Boneyard Bistro" 5 34.1477 -118.428 3] + [33 "My Brother's Bar-B-Q" 5 34.167 -118.595 2] + [35 "Smoke City Market" 5 34.1661 -118.448 1] + [37 "bigmista's barbecue" 5 34.118 -118.26 2] + [38 "Zeke's Smokehouse" 5 34.2053 -118.226 2] + [39 "Baby Blues BBQ" 5 34.0003 -118.465 2]] + (mt/formatted-rows :venues + (qp/process-query + (mt/query venues + {:query {:order-by [[:asc $id]]} + :parameters [{:type :string/starts-with + :target [:dimension $category_id->categories.name] + :value ["BB"]}]})))))))) (deftest test-mbql-parameters (testing "Should be able to pass parameters in to an MBQL query" From 795c1729ff0b9c8de5ce3994c235c6b7b9649fb4 Mon Sep 17 00:00:00 2001 From: Luis Paolini Date: Mon, 29 Mar 2021 21:32:01 -0300 Subject: [PATCH 6/7] Update common.clj (#15335) --- bin/i18n/src/i18n/common.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/i18n/src/i18n/common.clj b/bin/i18n/src/i18n/common.clj index 28d0c123e0716..379df14445aed 100644 --- a/bin/i18n/src/i18n/common.clj +++ b/bin/i18n/src/i18n/common.clj @@ -21,7 +21,7 @@ (defn- catalog ^Catalog [locale] (let [parser (PoParser.)] - (.parseCatalog parser (io/file (locale-source-po-filename "es"))))) + (.parseCatalog parser (io/file (locale-source-po-filename locale))))) (defn po-headers [locale] (when-let [^Message message (.locateHeader (catalog locale))] From 8014d511734ef0ad7843831dcd1f1029ae873761 Mon Sep 17 00:00:00 2001 From: Cam Saul <1455846+camsaul@users.noreply.github.com> Date: Tue, 30 Mar 2021 13:11:01 -0700 Subject: [PATCH 7/7] RIP Expectations tests 2015-2021 (No more old-style tests) (#15313) * No more old-style tests * Presto test fix :wrench: --- .../serialization/load_test.clj | 4 +- .../serialization/names_test.clj | 95 +-- .../test/metabase/driver/google_test.clj | 36 +- .../test/metabase/driver/mongo/util_test.clj | 378 ++++++----- .../test/metabase/driver/presto_test.clj | 35 +- .../test/metabase/driver/hive_like_test.clj | 17 +- test/expectations.clj | 163 ----- test/metabase/api/activity_test.clj | 119 ++-- test/metabase/api/embed_test.clj | 197 +++--- test/metabase/api/preview_embed_test.clj | 48 +- test/metabase/api/task_test.clj | 165 +++-- test/metabase/api/util_test.clj | 27 +- test/metabase/async/api_response_test.clj | 126 ++-- .../automagic_dashboards/rules_test.clj | 60 +- test/metabase/db/spec_test.clj | 75 +-- test/metabase/domain_entities/specs_test.clj | 10 +- test/metabase/events/dependencies_test.clj | 159 +++-- test/metabase/events/last_login_test.clj | 27 +- test/metabase/events/revision_test.clj | 589 +++++++++--------- test/metabase/metabot/events_test.clj | 180 +++--- test/metabase/metabot/instance_test.clj | 37 +- test/metabase/models/dashboard_card_test.clj | 335 +++++----- test/metabase/models/dependency_test.clj | 149 ++--- test/metabase/models/revision/diff_test.clj | 36 +- test/metabase/models/revision_test.clj | 398 ++++++------ test/metabase/models/session_test.clj | 40 +- test/metabase/models/task_history_test.clj | 66 +- test/metabase/plugins/classloader_test.clj | 55 +- .../middleware/constraints_test.clj | 81 ++- test/metabase/query_processor/store_test.clj | 80 +-- test/metabase/sample_dataset_test.clj | 70 +-- .../server/middleware/session_test.clj | 167 +++-- .../analyze/classifiers/category_test.clj | 19 +- .../classifiers/no_preview_display_test.clj | 42 +- test/metabase/sync/sync_dynamic_test.clj | 26 +- .../sync/sync_metadata/fields_test.clj | 225 ++++--- .../sync_metadata/sync_database_type_test.clj | 112 ++-- test/metabase/task_test.clj | 50 +- test/metabase/test/data/h2.clj | 22 +- test/metabase/test/data/impl.clj | 9 +- test/metabase/util/embed_test.clj | 24 +- test/metabase/util/encryption_test.clj | 120 ++-- test/metabase/util/password_test.clj | 3 +- 43 files changed, 2226 insertions(+), 2450 deletions(-) delete mode 100644 test/expectations.clj diff --git a/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj index 2e386a9acdda2..293dea75ec8b8 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/load_test.clj @@ -3,10 +3,10 @@ (:require [clojure.data :as diff] [clojure.java.io :as io] [clojure.test :refer [deftest is]] - [expectations :refer [expect]] [metabase-enterprise.serialization.cmd :refer [dump load]] [metabase-enterprise.serialization.test-util :as ts] - [metabase.models :refer [Card Collection Dashboard DashboardCard DashboardCardSeries Database Dependency Dimension Field FieldValues Metric Pulse PulseCard PulseChannel Segment Table User]] + [metabase.models :refer [Card Collection Dashboard DashboardCard DashboardCardSeries Database Dependency + Dimension Field FieldValues Metric Pulse PulseCard PulseChannel Segment Table User]] [metabase.test.data.users :as test-users] [metabase.util :as u] [toucan.db :as db]) diff --git a/enterprise/backend/test/metabase_enterprise/serialization/names_test.clj b/enterprise/backend/test/metabase_enterprise/serialization/names_test.clj index c2cbb6498ccd3..08890716e931a 100644 --- a/enterprise/backend/test/metabase_enterprise/serialization/names_test.clj +++ b/enterprise/backend/test/metabase_enterprise/serialization/names_test.clj @@ -1,66 +1,39 @@ (ns metabase-enterprise.serialization.names-test - (:require [expectations :refer :all] - [metabase-enterprise.serialization.names :as names :refer :all] + (:require [clojure.test :refer :all] + [metabase-enterprise.serialization.names :as names] [metabase-enterprise.serialization.test-util :as ts] [metabase.models :refer [Card Collection Dashboard Database Field Metric Segment Table]] [metabase.util :as u])) -(expect - (= (safe-name {:name "foo"}) "foo")) -(expect - (= (safe-name {:name "foo/bar baz"}) "foo%2Fbar baz")) - -(expect - (= (unescape-name "foo") "foo")) -(expect - (= (unescape-name "foo%2Fbar baz") "foo/bar baz")) - -(expect - (let [n "foo/bar baz"] - (= (-> {:name n} safe-name unescape-name (= n))))) - -(defn- test-fully-qualified-name-roundtrip - [entity] - (let [context (fully-qualified-name->context (fully-qualified-name entity))] - (= (u/get-id entity) ((some-fn :field :metric :segment :card :dashboard :collection :table :database) context)))) - -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Card card-id-root)))) -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Card card-id)))) -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Card card-id-nested)))) - -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Table table-id)))) - -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Field category-field-id)))) - -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Metric metric-id)))) - -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Segment segment-id)))) - -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Collection collection-id)))) -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Collection collection-id-nested)))) - -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Dashboard dashboard-id)))) - -(expect - (ts/with-world - (test-fully-qualified-name-roundtrip (Database db-id)))) +(deftest safe-name-test + (are [s expected] (= (names/safe-name {:name s}) expected) + "foo" "foo" + "foo/bar baz" "foo%2Fbar baz")) + +(deftest unescape-name-test + (are [s expected] (= expected + (names/unescape-name s)) + "foo" "foo" + "foo%2Fbar baz" "foo/bar baz")) + +(deftest safe-name-unescape-name-test + (is (= "foo/bar baz" + (-> {:name "foo/bar baz"} names/safe-name names/unescape-name)))) + +(deftest roundtrip-test + (ts/with-world + (doseq [object [(Card card-id-root) + (Card card-id) + (Card card-id-nested) + (Table table-id) + (Field category-field-id) + (Metric metric-id) + (Segment segment-id) + (Collection collection-id) + (Collection collection-id-nested) + (Dashboard dashboard-id) + (Database db-id)]] + (testing (class object) + (let [context (names/fully-qualified-name->context (names/fully-qualified-name object))] + (is (= (u/the-id object) + ((some-fn :field :metric :segment :card :dashboard :collection :table :database) context)))))))) diff --git a/modules/drivers/google/test/metabase/driver/google_test.clj b/modules/drivers/google/test/metabase/driver/google_test.clj index b9da90190f31e..2d1b5da199bc9 100644 --- a/modules/drivers/google/test/metabase/driver/google_test.clj +++ b/modules/drivers/google/test/metabase/driver/google_test.clj @@ -1,25 +1,23 @@ (ns metabase.driver.google-test - (:require [expectations :refer :all] + (:require [clojure.test :refer :all] [metabase.driver.google :as google])) -;; Typical scenario, all config information included -(expect - "Metabase/v0.30.0-snapshot (GPN:Metabase; NWNjNWY0Mw== master)" - (#'google/create-application-name {:tag "v0.30.0-snapshot", :hash "5cc5f43", :branch "master", :date "2018-08-21"})) +(deftest create-application-name-test + (testing "Typical scenario, all config information included" + (is (= "Metabase/v0.30.0-snapshot (GPN:Metabase; NWNjNWY0Mw== master)" + (#'google/create-application-name {:tag "v0.30.0-snapshot", :hash "5cc5f43", :branch "master", :date "2018-08-21"})))) -;; It's possible to have two hashes come back from our script. Sending a string with a newline in it for the -;; application name will cause Google connections to fail -(expect - "Metabase/v0.30.0-snapshot (GPN:Metabase; NWNjNWY0MwphYmNkZWYx master)" - (#'google/create-application-name {:tag "v0.30.0-snapshot", :hash "5cc5f43\nabcdef1", :branch "master", :date "2018-08-21"})) + (testing (str "It's possible to have two hashes come back from our script. Sending a string with a newline in it " + "for the application name will cause Google connections to fail") + (is (= "Metabase/v0.30.0-snapshot (GPN:Metabase; NWNjNWY0MwphYmNkZWYx master)" + (#'google/create-application-name {:tag "v0.30.0-snapshot", :hash "5cc5f43\nabcdef1", :branch "master", :date "2018-08-21"})))) -;; It's possible to have all ? values if there was some failure in reading version information, or if non was available -(expect - "Metabase/? (GPN:Metabase; Pw== ?)" - (#'google/create-application-name {:tag "?", :hash "?", :branch "?", :date "?"})) + (testing (str "It's possible to have all ? values if there was some failure in reading version information, or if " + "non was available") + (is (= "Metabase/? (GPN:Metabase; Pw== ?)" + (#'google/create-application-name {:tag "?", :hash "?", :branch "?", :date "?"})))) -;; This shouldn't be possible now that config/mb-version-info always returns a value, but testing an empty map just in -;; case -(expect - "Metabase/? (GPN:Metabase; ? ?)" - (#'google/create-application-name {})) + (testing (str "This shouldn't be possible now that config/mb-version-info always returns a value, but testing an " + "empty map just in case") + (is (= "Metabase/? (GPN:Metabase; ? ?)" + (#'google/create-application-name {}))))) diff --git a/modules/drivers/mongo/test/metabase/driver/mongo/util_test.clj b/modules/drivers/mongo/test/metabase/driver/mongo/util_test.clj index d29a64406ff75..49e5dd3e829a6 100644 --- a/modules/drivers/mongo/test/metabase/driver/mongo/util_test.clj +++ b/modules/drivers/mongo/test/metabase/driver/mongo/util_test.clj @@ -1,10 +1,8 @@ (ns metabase.driver.mongo.util-test (:require [clojure.test :refer :all] - [expectations :refer [expect]] [metabase.driver.mongo.util :as mongo-util] [metabase.driver.util :as driver.u] - [metabase.test :as mt] - [metabase.test.util.log :as tu.log]) + [metabase.test :as mt]) (:import [com.mongodb DB MongoClient MongoClientException ReadPreference ServerAddress])) (defn- connect-mongo [opts] @@ -20,205 +18,184 @@ (def srv-passthrough (fn [_] {:type :srv})) - -;; test hostname is fqdn - -(expect - true - (#'mongo-util/fqdn? "db.mongo.com")) - -(expect - true - (#'mongo-util/fqdn? "replica-01.db.mongo.com")) - -(expect - false - (#'mongo-util/fqdn? "localhost")) - -(expect - false - (#'mongo-util/fqdn? "localhost.localdomain")) - - -;; test srv connection string - -(expect - "mongodb+srv://test-user:test-pass@test-host.place.com/authdb" - (#'mongo-util/srv-conn-str "test-user" "test-pass" "test-host.place.com" "authdb")) - - -;; test that srv toggle works - -(expect - :srv - (with-redefs [mongo-util/srv-connection-info srv-passthrough - mongo-util/connect connect-passthrough] - (let [host "my.fake.domain" - opts {:host host - :port 1015 - :user "test-user" - :authdb "test-authdb" - :pass "test-passwd" - :dbname "test-dbname" - :ssl true - :additional-options "" - :use-srv true}] - (connect-mongo opts)))) - -(expect - :normal - (with-redefs [mongo-util/connect connect-passthrough] - (let [host "localhost" - opts {:host host - :port 1010 - :user "test-user" - :authdb "test-authdb" - :pass "test-passwd" - :dbname "test-dbname" - :ssl true - :additional-options "" - :use-srv false}] - (connect-mongo opts)))) - -(expect - :normal - (with-redefs [mongo-util/connect connect-passthrough] - (let [host "localhost.domain" - opts {:host host - :port 1010 - :user "test-user" - :authdb "test-authdb" - :pass "test-passwd" - :dbname "test-dbname" - :ssl true - :additional-options ""}] - (connect-mongo opts)))) - -;; test that connection properties when using srv - -(expect - "No SRV record available for host fake.fqdn.com" - (try - (let [host "fake.fqdn.com" - opts {:host host - :port 1015 - :user "test-user" - :authdb "test-authdb" - :pass "test-passwd" - :dbname "test-dbname" - :ssl true - :additional-options "" - :use-srv true} - [^MongoClient mongo-client ^DB db] (connect-mongo opts) - ^ServerAddress mongo-addr (-> mongo-client - (.getAllAddress) - first) - mongo-host (-> mongo-addr .getHost) - mongo-port (-> mongo-addr .getPort)] - [mongo-host mongo-port]) - (catch MongoClientException e - (.getMessage e)))) - -(expect - "Using DNS SRV requires a FQDN for host" - (try - (let [host "host1" - opts {:host host - :port 1015 - :user "test-user" - :authdb "test-authdb" - :pass "test-passwd" - :dbname "test-dbname" - :ssl true - :additional-options "" - :use-srv true} - [^MongoClient mongo-client ^DB db] (connect-mongo opts) - ^ServerAddress mongo-addr (-> mongo-client - (.getAllAddress) - first) - mongo-host (-> mongo-addr .getHost) - mongo-port (-> mongo-addr .getPort)] - [mongo-host mongo-port]) - (catch Exception e - (.getMessage e)))) - -(expect - "Unable to look up SRV record for host fake.fqdn.org" - (try - (let [host "fake.fqdn.org" - opts {:host host - :port 1015 - :user "test-user" - :authdb "test-authdb" - :pass "test-passwd" - :dbname "test-dbname" - :ssl true - :additional-options "" - :use-srv true} - [^MongoClient mongo-client ^DB db] (connect-mongo opts) - ^ServerAddress mongo-addr (-> mongo-client - (.getAllAddress) - first) - mongo-host (-> mongo-addr .getHost) - mongo-port (-> mongo-addr .getPort)] - [mongo-host mongo-port]) - (catch MongoClientException e - (.getMessage e)))) - -;; test host and port are correct for both srv and normal - -(expect - ["localhost" 1010] - (let [host "localhost" - opts {:host host - :port 1010 - :user "test-user" - :authdb "test-authdb" - :pass "test-passwd" - :dbname "test-dbname" - :ssl true - :additional-options ""} - [^MongoClient mongo-client ^DB db] (connect-mongo opts) - ^ServerAddress mongo-addr (-> mongo-client - (.getAllAddress) - first) - mongo-host (-> mongo-addr .getHost) - mongo-port (-> mongo-addr .getPort)] - [mongo-host mongo-port])) - - -;; test that people can specify additional connection options like `?readPreference=nearest` -(expect - (ReadPreference/nearest) - (.getReadPreference (-> (#'mongo-util/connection-options-builder :additional-options "readPreference=nearest") - .build))) - -(expect - (ReadPreference/secondaryPreferred) - (.getReadPreference (-> (#'mongo-util/connection-options-builder :additional-options "readPreference=secondaryPreferred") - .build))) - -;; make sure we can specify multiple options -(expect - "test" - (.getRequiredReplicaSetName (-> (#'mongo-util/connection-options-builder :additional-options "readPreference=secondary&replicaSet=test") - .build))) - -(expect - (ReadPreference/secondary) - (.getReadPreference (-> (#'mongo-util/connection-options-builder :additional-options "readPreference=secondary&replicaSet=test") - .build))) - -;; make sure that invalid additional options throw an Exception -(expect - IllegalArgumentException - (-> (#'mongo-util/connection-options-builder :additional-options "readPreference=ternary") - .build)) +(deftest fqdn?-test + (testing "test hostname is fqdn" + (is (= true + (#'mongo-util/fqdn? "db.mongo.com"))) + (is (= true + (#'mongo-util/fqdn? "replica-01.db.mongo.com"))) + (is (= false + (#'mongo-util/fqdn? "localhost"))) + (is (= false + (#'mongo-util/fqdn? "localhost.localdomain"))))) + +(deftest srv-conn-str-test + (testing "test srv connection string" + (is (= "mongodb+srv://test-user:test-pass@test-host.place.com/authdb" + (#'mongo-util/srv-conn-str "test-user" "test-pass" "test-host.place.com" "authdb"))))) + +(deftest srv-toggle-test + (testing "test that srv toggle works" + (is (= :srv + (with-redefs [mongo-util/srv-connection-info srv-passthrough + mongo-util/connect connect-passthrough] + (let [host "my.fake.domain" + opts {:host host + :port 1015 + :user "test-user" + :authdb "test-authdb" + :pass "test-passwd" + :dbname "test-dbname" + :ssl true + :additional-options "" + :use-srv true}] + (connect-mongo opts))))) + + (is (= :normal + (with-redefs [mongo-util/connect connect-passthrough] + (let [host "localhost" + opts {:host host + :port 1010 + :user "test-user" + :authdb "test-authdb" + :pass "test-passwd" + :dbname "test-dbname" + :ssl true + :additional-options "" + :use-srv false}] + (connect-mongo opts))))) + + (is (= :normal + (with-redefs [mongo-util/connect connect-passthrough] + (let [host "localhost.domain" + opts {:host host + :port 1010 + :user "test-user" + :authdb "test-authdb" + :pass "test-passwd" + :dbname "test-dbname" + :ssl true + :additional-options ""}] + (connect-mongo opts))))))) + +(deftest srv-connection-properties-test + (testing "test that connection properties when using srv" + (is (= "No SRV record available for host fake.fqdn.com" + (try + (let [host "fake.fqdn.com" + opts {:host host + :port 1015 + :user "test-user" + :authdb "test-authdb" + :pass "test-passwd" + :dbname "test-dbname" + :ssl true + :additional-options "" + :use-srv true} + [^MongoClient mongo-client ^DB db] (connect-mongo opts) + ^ServerAddress mongo-addr (-> mongo-client + (.getAllAddress) + first) + mongo-host (-> mongo-addr .getHost) + mongo-port (-> mongo-addr .getPort)] + [mongo-host mongo-port]) + (catch MongoClientException e + (.getMessage e))))) + + (is (= "Using DNS SRV requires a FQDN for host" + (try + (let [host "host1" + opts {:host host + :port 1015 + :user "test-user" + :authdb "test-authdb" + :pass "test-passwd" + :dbname "test-dbname" + :ssl true + :additional-options "" + :use-srv true} + [^MongoClient mongo-client ^DB db] (connect-mongo opts) + ^ServerAddress mongo-addr (-> mongo-client + (.getAllAddress) + first) + mongo-host (-> mongo-addr .getHost) + mongo-port (-> mongo-addr .getPort)] + [mongo-host mongo-port]) + (catch Exception e + (.getMessage e))))) + + (is (= "Unable to look up SRV record for host fake.fqdn.org" + (try + (let [host "fake.fqdn.org" + opts {:host host + :port 1015 + :user "test-user" + :authdb "test-authdb" + :pass "test-passwd" + :dbname "test-dbname" + :ssl true + :additional-options "" + :use-srv true} + [^MongoClient mongo-client ^DB db] (connect-mongo opts) + ^ServerAddress mongo-addr (-> mongo-client + (.getAllAddress) + first) + mongo-host (-> mongo-addr .getHost) + mongo-port (-> mongo-addr .getPort)] + [mongo-host mongo-port]) + (catch MongoClientException e + (.getMessage e))))) + + (testing "test host and port are correct for both srv and normal" + (let [host "localhost" + opts {:host host + :port 1010 + :user "test-user" + :authdb "test-authdb" + :pass "test-passwd" + :dbname "test-dbname" + :ssl true + :additional-options ""} + [^MongoClient mongo-client ^DB db] (connect-mongo opts) + ^ServerAddress mongo-addr (-> mongo-client + (.getAllAddress) + first) + mongo-host (-> mongo-addr .getHost) + mongo-port (-> mongo-addr .getPort)] + (is (= "localhost" + mongo-host)) + (is (= 1010 + mongo-port)))))) + +(deftest additional-connection-options-test + (testing "test that people can specify additional connection options like `?readPreference=nearest`" + (is (= (ReadPreference/nearest) + (.getReadPreference (-> (#'mongo-util/connection-options-builder :additional-options "readPreference=nearest") + .build)))) + + (is (= (ReadPreference/secondaryPreferred) + (.getReadPreference (-> (#'mongo-util/connection-options-builder :additional-options "readPreference=secondaryPreferred") + .build)))) + + (testing "make sure we can specify multiple options" + (let [opts (-> (#'mongo-util/connection-options-builder :additional-options "readPreference=secondary&replicaSet=test") + .build)] + (is (= "test" + (.getRequiredReplicaSetName opts))) + + (is (= (ReadPreference/secondary) + (.getReadPreference opts))))) + + (testing "make sure that invalid additional options throw an Exception" + (is (thrown-with-msg? + IllegalArgumentException + #"No match for read preference of ternary" + (-> (#'mongo-util/connection-options-builder :additional-options "readPreference=ternary") + .build)))))) (deftest test-ssh-connection (testing "Gets an error when it can't connect to mongo via ssh tunnel" - (mt/test-driver - :mongo + (mt/test-driver :mongo (is (thrown? java.net.ConnectException (try @@ -237,8 +214,7 @@ ;; doesn't wrap every exception in an SshdException :tunnel-port 21212 :tunnel-user "bogus"}] - (tu.log/suppress-output - (driver.u/can-connect-with-details? engine details :throw-exceptions))) + (driver.u/can-connect-with-details? engine details :throw-exceptions)) (catch Throwable e (loop [^Throwable e e] (or (when (instance? java.net.ConnectException e) diff --git a/modules/drivers/presto/test/metabase/driver/presto_test.clj b/modules/drivers/presto/test/metabase/driver/presto_test.clj index 3249391b9a24d..61c55ed5f1afa 100644 --- a/modules/drivers/presto/test/metabase/driver/presto_test.clj +++ b/modules/drivers/presto/test/metabase/driver/presto_test.clj @@ -2,7 +2,7 @@ (:require [clj-http.client :as http] [clojure.core.async :as a] [clojure.test :refer :all] - [expectations :refer [expect]] + [honeysql.core :as hsql] [java-time :as t] [metabase.db.metadata-queries :as metadata-queries] [metabase.driver :as driver] @@ -125,22 +125,23 @@ (constantly conj))))))) -;;; APPLY-PAGE -(expect - {:select ["name" "id"] - :from [{:select [[:default.categories.name "name"] - [:default.categories.id "id"] - [{:s "row_number() OVER (ORDER BY \"default\".\"categories\".\"id\" ASC)"} :__rownum__]] - :from [:default.categories] - :order-by [[:default.categories.id :asc]]}] - :where [:> :__rownum__ 5] - :limit 5} - (sql.qp/apply-top-level-clause :presto :page - {:select [[:default.categories.name "name"] [:default.categories.id "id"]] - :from [:default.categories] - :order-by [[:default.categories.id :asc]]} - {:page {:page 2 - :items 5}})) +(deftest page-test + (testing ":page clause" + (is (= {:select ["name" "id"] + :from [{:select [[:default.categories.name "name"] + [:default.categories.id "id"] + [(hsql/raw "row_number() OVER (ORDER BY \"default\".\"categories\".\"id\" ASC)") + :__rownum__]] + :from [:default.categories] + :order-by [[:default.categories.id :asc]]}] + :where [:> :__rownum__ 5] + :limit 5} + (sql.qp/apply-top-level-clause :presto :page + {:select [[:default.categories.name "name"] [:default.categories.id "id"]] + :from [:default.categories] + :order-by [[:default.categories.id :asc]]} + {:page {:page 2 + :items 5}}))))) (deftest test-connect-via-tunnel (testing "connection fails as expected" diff --git a/modules/drivers/sparksql/test/metabase/driver/hive_like_test.clj b/modules/drivers/sparksql/test/metabase/driver/hive_like_test.clj index fe62c8bab9cd3..caf8599732c00 100644 --- a/modules/drivers/sparksql/test/metabase/driver/hive_like_test.clj +++ b/modules/drivers/sparksql/test/metabase/driver/hive_like_test.clj @@ -1,10 +1,13 @@ (ns metabase.driver.hive-like-test - (:require [expectations :refer [expect]] + (:require [clojure.test :refer :all] [metabase.driver.sql-jdbc.sync :as sql-jdbc.sync])) -;; make sure the various types we use for running tests are actually mapped to the correct DB type -(expect :type/Text (sql-jdbc.sync/database-type->base-type :hive-like :string)) -(expect :type/Integer (sql-jdbc.sync/database-type->base-type :hive-like :int)) -(expect :type/Date (sql-jdbc.sync/database-type->base-type :hive-like :date)) -(expect :type/DateTime (sql-jdbc.sync/database-type->base-type :hive-like :timestamp)) -(expect :type/Float (sql-jdbc.sync/database-type->base-type :hive-like :double)) +(deftest database-type->base-type-test + (testing "make sure the various types we use for running tests are actually mapped to the correct DB type" + (are [db-type expected] (= expected + (sql-jdbc.sync/database-type->base-type :hive-like db-type)) + :string :type/Text + :int :type/Integer + :date :type/Date + :timestamp :type/DateTime + :double :type/Float))) diff --git a/test/expectations.clj b/test/expectations.clj deleted file mode 100644 index d97b1ded8af56..0000000000000 --- a/test/expectations.clj +++ /dev/null @@ -1,163 +0,0 @@ -(ns ^:deprecated expectations - (:require [clojure.data :as data] - [clojure.test :as t] - [environ.core :as env] - [metabase.config :as config] - [metabase.util :as u] - [methodical.core :as m])) - -(alter-meta! *ns* assoc :deprecated true) - -;; Basically a Chain of Responibility pattern: we try each impl in turn until one of them accepts the args and returns -;; a report -(m/defmulti ^:private compare-expr* - {:arglists '([expected actual form])} - :none - :combo (m/or-method-combination) - :dispatcher (m/everything-dispatcher)) - -(defrecord ExceptionResult [e]) - -;; When `actual` throws an Exception. Result is wrapped in ExceptionResult -(m/defmethod compare-expr* :exception-thrown - [expected actual _] - (when (instance? ExceptionResult actual) - (let [{:keys [e]} actual] - (cond - (not (isa? expected Throwable)) - (throw e) - - (not (instance? expected e)) - {:type :fail - :expected (format "Threw %s" expected) - :actual (format "Threw %s" (class e))} - - :else - {:type :pass - :actual (class e)})))) - -;; If an Exception was expected, but none was thrown -(m/defmethod compare-expr* :expected-exception-none-thrown - [expected actual _] - (when (and (isa? expected Throwable) - (not (instance? ExceptionResult actual))) - (if (instance? expected actual) - {:type :pass} - {:type :fail - :expected (format "Threw %s" expected)}))) - -(m/defmethod compare-expr* :truthy - [expected actual _] - (when (= expected ::truthy) - (if actual - {:type :pass} - {:type :fail, :expected "A truthy value"}))) - -(m/defmethod compare-expr* :fn - [expected actual [_ e a]] - (when (fn? expected) - (if (expected actual) - {:type :pass} - {:type :fail, :expected (list e a)}))) - -(m/defmethod compare-expr* :regex - [expected actual _] - (when (instance? java.util.regex.Pattern expected) - {:type (if (re-find expected actual) - :pass - :fail)})) - -(m/defmethod compare-expr* :maps - [expected actual _] - (when (and (map? expected) - (map? actual) - (not (instance? ExceptionResult actual))) - (let [[only-in-e only-in-a] (data/diff expected actual)] - (if (and (nil? only-in-e) (nil? only-in-a)) - {:type :pass} - {:type :fail - :actual [actual] - :diffs [[actual [only-in-e only-in-a]]]})))) - -(m/defmethod compare-expr* :expected-class - [expected actual _] - (when (and (class? expected) - (not (instance? ExceptionResult actual))) - (cond - (instance? expected actual) - {:type :pass} - - (= expected actual) - {:type :pass} - - :else - {:type :fail - :expected (format "Instance of %s" expected) - :actual actual}))) - -(defn- default-compare-expr [expected actual _] - (if (= expected actual) - {:type :pass} - {:type :fail - :diffs [[actual (take 2 (data/diff expected actual))]]})) - -(defn compare-expr [expected actual message form] - (merge - {:message message - :expected expected - :actual actual} - (or (compare-expr* expected actual form) - (default-compare-expr expected actual form)))) - -(defmethod t/assert-expr 'expect= [msg [_ e a :as form]] - `(let [a# (try - ~a - (catch Throwable e# - (->ExceptionResult e#)))] - (t/do-report - (compare-expr ~e a# ~msg '~form)))) - -;; each time we encounter a new expectations-style test, record a `namespace:line` symbol in `symbols` so we can -;; display some stats on the total number of old-style tests when running tests, and make sure no one adds any new -;; ones -(def symbols (atom #{})) - -(t/deftest no-new-expectations-style-tests-test - (let [total-expect-forms (count @symbols) - total-namespaces-using-expect (count (into #{} (map namespace @symbols))) - [worst-ns worst-ns-symbols] (when (seq @symbols) - (apply max-key (comp count second) (seq (group-by namespace @symbols))))] - (println (u/format-color 'red "Total old-style expectations tests: %d" total-expect-forms)) - (println (u/format-color 'red "Total namespaces still using old-style expectations tests: %d" total-namespaces-using-expect)) - (when worst-ns - (println (u/format-color 'red "Who has the most? %s with %d old-style tests" worst-ns (count worst-ns-symbols)))) - ;; only check total test forms when driver-specific tests are off! Otherwise this number can change without us - ;; expecting it. - (when-not (env/env :drivers) - (t/testing "Don't write any new tests using expect!" - (let [ee? (u/ignore-exceptions (require 'metabase-enterprise.core) true) - oss-forms 178 - ee-forms 25 - oss-namespaces 32 - ee-namespaces 3] - - (t/is (<= total-expect-forms (cond-> oss-forms ee? (+ ee-forms)))) - (t/is (<= total-namespaces-using-expect (cond-> oss-namespaces ee? (+ ee-namespaces))))))))) - -(defmacro ^:deprecated expect - "Simple macro that simulates converts an Expectations-style `expect` form into a `clojure.test` `deftest` form." - {:arglists '([actual] [actual expected] [test-name actual expected])} - ([actual] - `(expect ::truthy ~actual)) - - ([expected actual] - `(expect ~(symbol (format "expect-%d" (hash &form))) ~expected ~actual)) - - ([test-name expected actual] - `(do - (t/deftest ~test-name - (t/testing (format ~(str (name (ns-name *ns*)) ":%d") (:line (meta #'~test-name))) - (t/is - (~'expect= ~expected ~actual)))) - (when config/is-test? - (swap! symbols conj (symbol ~(name (ns-name *ns*)) (str (:line (meta #'~test-name))))))))) diff --git a/test/metabase/api/activity_test.clj b/test/metabase/api/activity_test.clj index dd842c7d3a389..eaa8b0b4984bc 100644 --- a/test/metabase/api/activity_test.clj +++ b/test/metabase/api/activity_test.clj @@ -1,18 +1,16 @@ (ns metabase.api.activity-test "Tests for /api/activity endpoints." (:require [clojure.test :refer :all] - [expectations :refer [expect]] [metabase.api.activity :as activity-api] [metabase.db :as mdb] [metabase.models.activity :refer [Activity]] [metabase.models.card :refer [Card]] [metabase.models.dashboard :refer [Dashboard]] [metabase.models.view-log :refer [ViewLog]] - [metabase.test.data.users :as test-users] + [metabase.test :as mt] [metabase.test.fixtures :as fixtures] [metabase.util :as u] - [toucan.db :as db] - [toucan.util.test :as tt])) + [toucan.db :as db])) (use-fixtures :once (fixtures/initialize :db)) @@ -32,33 +30,33 @@ (defn- activity-user-info [user-kw] (merge - {:id (test-users/user->id user-kw)} + {:id (mt/user->id user-kw)} (select-keys - (test-users/fetch-user user-kw) + (mt/fetch-user user-kw) [:common_name :date_joined :email :first_name :is_qbnewb :is_superuser :last_login :last_name :locale]))) ;; NOTE: timestamp matching was being a real PITA so I cheated a bit. ideally we'd fix that (deftest activity-list-test (testing "GET /api/activity" - (tt/with-temp* [Activity [activity1 {:topic "install" + (mt/with-temp* [Activity [activity1 {:topic "install" :details {} :timestamp #t "2015-09-09T12:13:14.888Z[UTC]"}] Activity [activity2 {:topic "dashboard-create" - :user_id (test-users/user->id :crowberto) + :user_id (mt/user->id :crowberto) :model "dashboard" :model_id 1234 :details {:description "Because I can!" :name "Bwahahaha"} :timestamp #t "2015-09-10T18:53:01.632Z[UTC]"}] Activity [activity3 {:topic "user-joined" - :user_id (test-users/user->id :rasta) + :user_id (mt/user->id :rasta) :model "user" :details {} :timestamp #t "2015-09-10T05:33:43.641Z[UTC]"}]] (letfn [(fetch-activity [activity] (merge activity-defaults - (db/select-one [Activity :id :user_id :details :model :model_id] :id (u/get-id activity))))] + (db/select-one [Activity :id :user_id :details :model :model_id] :id (u/the-id activity))))] (is (= [(merge (fetch-activity activity2) {:topic "dashboard-create" @@ -73,9 +71,9 @@ :user_id nil :user nil})] ;; remove other activities from the API response just in case -- we're not interested in those - (let [these-activity-ids (set (map u/get-id [activity1 activity2 activity3]))] - (for [activity ((test-users/user->client :crowberto) :get 200 "activity") - :when (contains? these-activity-ids (u/get-id activity))] + (let [these-activity-ids (set (map u/the-id [activity1 activity2 activity3]))] + (for [activity (mt/user-http-request :crowberto :get 200 "activity") + :when (contains? these-activity-ids (u/the-id activity))] (dissoc activity :timestamp))))))))) ;;; GET /recent_views @@ -100,24 +98,24 @@ 10))) (deftest recent-views-test - (tt/with-temp* [Card [card1 {:name "rand-name" - :creator_id (test-users/user->id :crowberto) + (mt/with-temp* [Card [card1 {:name "rand-name" + :creator_id (mt/user->id :crowberto) :display "table" :visualization_settings {}}] Dashboard [dash1 {:name "rand-name" :description "rand-name" - :creator_id (test-users/user->id :crowberto)}] + :creator_id (mt/user->id :crowberto)}] Card [card2 {:name "rand-name" - :creator_id (test-users/user->id :crowberto) + :creator_id (mt/user->id :crowberto) :display "table" :visualization_settings {}}]] - (create-view! (test-users/user->id :crowberto) "card" (:id card2)) - (create-view! (test-users/user->id :crowberto) "dashboard" (:id dash1)) - (create-view! (test-users/user->id :crowberto) "card" (:id card1)) - (create-view! (test-users/user->id :crowberto) "card" 36478) - (create-view! (test-users/user->id :rasta) "card" (:id card1)) + (create-view! (mt/user->id :crowberto) "card" (:id card2)) + (create-view! (mt/user->id :crowberto) "dashboard" (:id dash1)) + (create-view! (mt/user->id :crowberto) "card" (:id card1)) + (create-view! (mt/user->id :crowberto) "card" 36478) + (create-view! (mt/user->id :rasta) "card" (:id card1)) (is (= [{:cnt 1 - :user_id (test-users/user->id :crowberto) + :user_id (mt/user->id :crowberto) :model "card" :model_id (:id card1) :model_object {:id (:id card1) @@ -126,7 +124,7 @@ :description (:description card1) :display (name (:display card1))}} {:cnt 1 - :user_id (test-users/user->id :crowberto) + :user_id (mt/user->id :crowberto) :model "dashboard" :model_id (:id dash1) :model_object {:id (:id dash1) @@ -134,7 +132,7 @@ :collection_id nil :description (:description dash1)}} {:cnt 1 - :user_id (test-users/user->id :crowberto) + :user_id (mt/user->id :crowberto) :model "card" :model_id (:id card2) :model_object {:id (:id card2) @@ -142,7 +140,7 @@ :collection_id nil :description (:description card2) :display (name (:display card2))}}] - (for [recent-view ((test-users/user->client :crowberto) :get 200 "activity/recent_views")] + (for [recent-view (mt/user-http-request :crowberto :get 200 "activity/recent_views")] (dissoc recent-view :max_ts)))))) @@ -163,45 +161,52 @@ {:model "user", :model_id 90, :topic :user-joined, :details {}} {:model nil, :model_id nil, :topic :install, :details {}}]) -(expect - {"dashboard" #{41 43 42} - "card" #{113 108 109 111 112 114} - "user" #{90}} - (#'activity-api/activities->referenced-objects fake-activities)) - - -(tt/expect-with-temp [Dashboard [{dashboard-id :id}]] - {"dashboard" #{dashboard-id}, "card" nil} - (#'activity-api/referenced-objects->existing-objects {"dashboard" #{dashboard-id 0} - "card" #{0}})) - - -(tt/expect-with-temp [Dashboard [{dashboard-id :id}] - Card [{card-id :id}]] - [{:model "dashboard", :model_id dashboard-id, :model_exists true} - {:model "card", :model_id 0, :model_exists false} - {:model "dashboard", :model_id 0, :model_exists false, :topic :dashboard-remove-cards, :details {:dashcards [{:card_id card-id, :exists true} - {:card_id 0, :exists false}]}}] - (#'activity-api/add-model-exists-info [{:model "dashboard", :model_id dashboard-id} - {:model "card", :model_id 0} - {:model "dashboard", :model_id 0, :topic :dashboard-remove-cards, :details {:dashcards [{:card_id card-id} - {:card_id 0}]}}])) +(deftest activities->referenced-objects-test + (is (= {"dashboard" #{41 43 42} + "card" #{113 108 109 111 112 114} + "user" #{90}} + (#'activity-api/activities->referenced-objects fake-activities)))) + + +(deftest referenced-objects->existing-objects-test + (mt/with-temp Dashboard [{dashboard-id :id}] + (is (= {"dashboard" #{dashboard-id}, "card" nil} + (#'activity-api/referenced-objects->existing-objects {"dashboard" #{dashboard-id 0} + "card" #{0}}))))) +(deftest add-model-exists-info-test + (mt/with-temp* [Dashboard [{dashboard-id :id}] + Card [{card-id :id}]] + (is (= [{:model "dashboard", :model_id dashboard-id, :model_exists true} + {:model "card", :model_id 0, :model_exists false} + {:model "dashboard" + :model_id 0 + :model_exists false + :topic :dashboard-remove-cards + :details {:dashcards [{:card_id card-id, :exists true} + {:card_id 0, :exists false}]}}] + (#'activity-api/add-model-exists-info [{:model "dashboard", :model_id dashboard-id} + {:model "card", :model_id 0} + {:model "dashboard" + :model_id 0 + :topic :dashboard-remove-cards + :details {:dashcards [{:card_id card-id} + {:card_id 0}]}}]))))) (defn- user-can-see-user-joined-activity? [user] ;; clear out all existing Activity entries (db/delete! Activity) - (-> (tt/with-temp Activity [activity {:topic "user-joined" + (-> (mt/with-temp Activity [activity {:topic "user-joined" :details {} :timestamp #t "2019-02-15T11:55:00.000Z"}] - ((test-users/user->client user) :get 200 "activity")) + (mt/user-http-request user :get 200 "activity")) seq boolean)) (deftest activity-visibility-test (testing "Only admins should get to see user-joined activities" - (is (= true - (user-can-see-user-joined-activity? :crowberto)) - "admin should see `:user-joined` activities") - (is (= false - (user-can-see-user-joined-activity? :rasta)) - "non-admin should *not* see `:user-joined` activities"))) + (testing "admin should see `:user-joined` activities" + (is (= true + (user-can-see-user-joined-activity? :crowberto)))) + (testing "non-admin should *not* see `:user-joined` activities" + (is (= false + (user-can-see-user-joined-activity? :rasta)))))) diff --git a/test/metabase/api/embed_test.clj b/test/metabase/api/embed_test.clj index f1012553ba373..485c62e938b38 100644 --- a/test/metabase/api/embed_test.clj +++ b/test/metabase/api/embed_test.clj @@ -19,10 +19,8 @@ [metabase.query-processor-test :as qp.test] [metabase.query-processor.middleware.constraints :as constraints] [metabase.test :as mt] - [metabase.test.util :as tu] [metabase.util :as u] - [toucan.db :as db] - [toucan.util.test :as tt]) + [toucan.db :as db]) (:import java.io.ByteArrayInputStream)) (defn random-embedding-secret-key [] (crypto-random/hex 32)) @@ -33,19 +31,19 @@ (defn do-with-new-secret-key [f] (binding [*secret-key* (random-embedding-secret-key)] - (tu/with-temporary-setting-values [embedding-secret-key *secret-key*] + (mt/with-temporary-setting-values [embedding-secret-key *secret-key*] (f)))) (defmacro with-new-secret-key {:style/indent 0} [& body] `(do-with-new-secret-key (fn [] ~@body))) (defn card-token {:style/indent 1} [card-or-id & [additional-token-params]] - (sign (merge {:resource {:question (u/get-id card-or-id)} + (sign (merge {:resource {:question (u/the-id card-or-id)} :params {}} additional-token-params))) (defn dash-token {:style/indent 1} [dash-or-id & [additional-token-params]] - (sign (merge {:resource {:dashboard (u/get-id dash-or-id)} + (sign (merge {:resource {:dashboard (u/the-id dash-or-id)} :params {}} additional-token-params))) @@ -54,42 +52,52 @@ card-settings# (merge (when-not (:dataset_query card-defaults#) (public-test/count-of-venues-card)) card-defaults#)] - (tt/with-temp Card [~card-binding card-settings#] + (mt/with-temp Card [~card-binding card-settings#] ~@body))) (defmacro with-temp-dashcard {:style/indent 1} [[dashcard-binding {:keys [dash card dashcard]}] & body] `(with-temp-card [card# ~card] - (tt/with-temp* [Dashboard [dash# ~dash] - DashboardCard [~dashcard-binding (merge {:card_id (u/get-id card#) - :dashboard_id (u/get-id dash#)} + (mt/with-temp* [Dashboard [dash# ~dash] + DashboardCard [~dashcard-binding (merge {:card_id (u/the-id card#) + :dashboard_id (u/the-id dash#)} ~dashcard)]] ~@body))) (defmacro with-embedding-enabled-and-new-secret-key {:style/indent 0} [& body] - `(tu/with-temporary-setting-values [~'enable-embedding true] + `(mt/with-temporary-setting-values [~'enable-embedding true] (with-new-secret-key ~@body))) -(defn successful-query-results - ([] - {:data {:cols [(tu/obj->json->obj (qp.test/aggregate-col :count))] - :rows [[100]] - :insights nil - :results_timezone "UTC"} - :json_query {} - :status "completed"}) - - ([results-format] +(defn test-query-results + ([actual] + (is (= {:data {:cols [(mt/obj->json->obj (qp.test/aggregate-col :count))] + :rows [[100]] + :insights nil + :results_timezone "UTC"} + :json_query {} + :status "completed"} + actual))) + + ([results-format actual] (case results-format - "" (successful-query-results) - "/json" [{:Count 100}] - "/csv" "Count\n100\n" - "/xlsx" (fn [body] - (->> (ByteArrayInputStream. body) - spreadsheet/load-workbook - (spreadsheet/select-sheet "Query result") - (spreadsheet/select-columns {:A :col}) - (= [{:col "Count"} {:col 100.0}])))))) + "" + (test-query-results actual) + + "/json" + (is (= [{:Count 100}] + actual)) + + "/csv" + (is (= "Count\n100\n" + actual)) + + "/xlsx" + (let [actual (->> (ByteArrayInputStream. actual) + spreadsheet/load-workbook + (spreadsheet/select-sheet "Query result") + (spreadsheet/select-columns {:A :col}))] + (is (= [{:col "Count"} {:col 100.0}] + actual)))))) (defn dissoc-id-and-name {:style/indent 0} [obj] (dissoc obj :id :name)) @@ -129,7 +137,7 @@ (http/client :get 400 (card-url card {:exp (buddy-util/to-timestamp yesterday)}))))))) (deftest check-that-the-endpoint-doesn-t-work-if-embedding-isn-t-enabled - (tu/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding false] (with-new-secret-key (with-temp-card [card] (is (= "Embedding is not enabled." @@ -194,7 +202,7 @@ (testing "GET /api/embed/card/:token/query and GET /api/embed/card/:token/query/:export-format" (do-response-formats [response-format request-options] (testing "check that the endpoint doesn't work if embedding isn't enabled" - (tu/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding false] (with-new-secret-key (with-temp-card [card] (is (= "Embedding is not enabled." @@ -204,8 +212,10 @@ (let [expected-status (response-format->status-code response-format)] (testing "it should be possible to run a Card successfully if you jump through the right hoops..." (with-temp-card [card {:enable_embedding true}] - (is (expect= (successful-query-results response-format) - (http/client :get expected-status (card-query-url card response-format) {:request-options request-options}))))) + (test-query-results + response-format + (http/client :get expected-status (card-query-url card response-format) + {:request-options request-options})))) (testing (str "...but if the card has an invalid query we should just get a generic \"query failed\" " "exception (rather than leaking query info)") @@ -254,10 +264,11 @@ (http/client :get 400 (card-query-url card response-format))))) (testing "if `:locked` param is present, request should succeed" - (is (expect= (successful-query-results response-format) - (http/client :get (response-format->status-code response-format) - (card-query-url card response-format {:params {:abc 100}}) - {:request-options request-options})))) + (test-query-results + response-format + (http/client :get (response-format->status-code response-format) + (card-query-url card response-format {:params {:abc 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 :abc in the JWT." @@ -286,16 +297,18 @@ (http/client :get 400 (str (card-query-url card response-format {:params {:abc 100}}) "?abc=200"))))) (testing "If an `:enabled` param is present in the JWT, that's ok" - (is (expect= (successful-query-results response-format) - (http/client :get (response-format->status-code response-format) - (card-query-url card response-format {:params {:abc "enabled"}}) - {:request-options request-options})))) + (test-query-results + response-format + (http/client :get (response-format->status-code response-format) + (card-query-url card response-format {:params {:abc "enabled"}}) + {:request-options request-options}))) (testing "If an `:enabled` param is present in URL params but *not* the JWT, that's ok" - (is (expect= (successful-query-results response-format) - (http/client :get (response-format->status-code response-format) - (str (card-query-url card response-format) "?abc=200") - {:request-options request-options})))))))) + (test-query-results + response-format + (http/client :get (response-format->status-code response-format) + (str (card-query-url card response-format) "?abc=200") + {:request-options request-options}))))))) (defn- card-with-date-field-filter [] {:dataset_query {:database (mt/id) @@ -312,16 +325,16 @@ (deftest csv-reports-count (testing "make sure CSV (etc.) downloads take editable params into account (#6407)" (with-embedding-enabled-and-new-secret-key - (tt/with-temp Card [card (card-with-date-field-filter)] + (mt/with-temp Card [card (card-with-date-field-filter)] (is (= "count\n107\n" (http/client :get 200 (str (card-query-url card "/csv") "?date=Q1-2014")))))))) (deftest csv-forward-url-test (with-embedding-enabled-and-new-secret-key - (tt/with-temp Card [card (card-with-date-field-filter)] + (mt/with-temp Card [card (card-with-date-field-filter)] ;; make sure the URL doesn't include /api/ at the beginning like it normally would (binding [http/*url-prefix* (str/replace http/*url-prefix* #"/api/$" "/")] - (tu/with-temporary-setting-values [site-url http/*url-prefix*] + (mt/with-temporary-setting-values [site-url http/*url-prefix*] (is (= "count\n107\n" (http/client :get 200 (str "embed/question/" (card-token card) ".csv?date=Q1-2014"))))))))) @@ -333,27 +346,27 @@ (deftest it-should-be-possible-to-call-this-endpoint-successfully (with-embedding-enabled-and-new-secret-key - (tt/with-temp Dashboard [dash {:enable_embedding true}] + (mt/with-temp Dashboard [dash {:enable_embedding true}] (is (= successful-dashboard-info (dissoc-id-and-name (http/client :get 200 (dashboard-url dash)))))))) (deftest we-should-fail-when-attempting-to-use-an-expired-token (with-embedding-enabled-and-new-secret-key - (tt/with-temp Dashboard [dash {:enable_embedding true}] + (mt/with-temp Dashboard [dash {:enable_embedding true}] (is (re= #"^Token is expired.*" (http/client :get 400 (dashboard-url dash {:exp (buddy-util/to-timestamp yesterday)}))))))) (deftest check-that-the-dashboard-endpoint-doesn-t-work-if-embedding-isn-t-enabled - (tu/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding false] (with-new-secret-key - (tt/with-temp Dashboard [dash] + (mt/with-temp Dashboard [dash] (is (= "Embedding is not enabled." (http/client :get 400 (dashboard-url dash)))))))) (deftest check-that-if-embedding--is--enabled-globally-but-not-for-the-dashboard-the-request-fails (with-embedding-enabled-and-new-secret-key - (tt/with-temp Dashboard [dash] + (mt/with-temp Dashboard [dash] (is (= "Embedding is not enabled for this object." (http/client :get 400 (dashboard-url dash))))))) @@ -361,14 +374,14 @@ (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-embedding-enabled-and-new-secret-key - (tt/with-temp Dashboard [dash {:enable_embedding true}] + (mt/with-temp Dashboard [dash {:enable_embedding true}] (is (= "Message seems corrupt or manipulated." (http/client :get 400 (with-new-secret-key (dashboard-url dash))))))))) (deftest only-enabled-params-that-are-not-present-in-the-jwt-come-back (testing "check that only ENABLED params that ARE NOT PRESENT IN THE JWT come back" (with-embedding-enabled-and-new-secret-key - (tt/with-temp Dashboard [dash {:enable_embedding true + (mt/with-temp Dashboard [dash {:enable_embedding true :embedding_params {:a "locked", :b "disabled", :c "enabled", :d "enabled"} :parameters [{:id "_a", :slug "a", :name "a", :type "date"} {:id "_b", :slug "b", :name "b", :type "date"} @@ -382,15 +395,14 @@ (defn- dashcard-url [dashcard & [additional-token-params]] (str "embed/dashboard/" (dash-token (:dashboard_id dashcard) additional-token-params) - "/dashcard/" (u/get-id dashcard) + "/dashcard/" (u/the-id dashcard) "/card/" (:card_id dashcard))) -;; it should be possible to run a Card successfully if you jump through the right hoops... (deftest it-should-be-possible-to-run-a-card-successfully-if-you-jump-through-the-right-hoops--- - (is (expect= (successful-query-results) - (with-embedding-enabled-and-new-secret-key - (with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] - (http/client :get 202 (dashcard-url dashcard))))))) + (testing "it should be possible to run a Card successfully if you jump through the right hoops..." + (with-embedding-enabled-and-new-secret-key + (with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] + (test-query-results (http/client :get 202 (dashcard-url dashcard))))))) (deftest downloading-csv-json-xlsx-results-from-the-dashcard-endpoint-shouldn-t-be-subject-to-the-default-query-constraints @@ -419,7 +431,7 @@ (http/client :get 202 (dashcard-url dashcard)))))))) (deftest check-that-the-dashcard-endpoint-doesn-t-work-if-embedding-isn-t-enabled - (tu/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding false] (with-new-secret-key (with-temp-dashcard [dashcard] (is (= "Embedding is not enabled." @@ -448,8 +460,7 @@ (http/client :get 400 (dashcard-url dashcard))))) (testing "if `:locked` param is supplied, request should succeed" - (is (expect= (successful-query-results) - (http/client :get 202 (dashcard-url dashcard {:params {:abc 100}}))))) + (test-query-results (http/client :get 202 (dashcard-url dashcard {:params {:abc 100}})))) (testing "if `:locked` parameter is present in URL params, request should fail" (is (= "You must specify a value for :abc in the JWT." @@ -476,12 +487,10 @@ (testing "If an `:enabled` param is present in the JWT, that's ok" - (is (expect= (successful-query-results) - (http/client :get 202 (dashcard-url dashcard {:params {:abc 100}}))))) + (test-query-results (http/client :get 202 (dashcard-url dashcard {:params {:abc 100}})))) (testing "If an `:enabled` param is present in URL params but *not* the JWT, that's ok" - (is (expect= (successful-query-results) - (http/client :get 202 (str (dashcard-url dashcard) "?abc=200")))))))) + (test-query-results (http/client :get 202 (str (dashcard-url dashcard) "?abc=200"))))))) ;;; -------------------------------------------------- Other Tests --------------------------------------------------- @@ -496,15 +505,15 @@ (deftest make-sure-that-multiline-series-word-as-expected---4768- (testing "make sure that multiline series word as expected (#4768)" (with-embedding-enabled-and-new-secret-key - (tt/with-temp Card [series-card {:dataset_query {:database (mt/id) + (mt/with-temp Card [series-card {:dataset_query {:database (mt/id) :type :query :query {:source-table (mt/id :venues)}}}] (with-temp-dashcard [dashcard {:dash {:enable_embedding true}}] - (tt/with-temp DashboardCardSeries [series {:dashboardcard_id (u/get-id dashcard) - :card_id (u/get-id series-card) + (mt/with-temp DashboardCardSeries [series {:dashboardcard_id (u/the-id dashcard) + :card_id (u/the-id series-card) :position 0}] (is (= "completed" - (:status (http/client :get 202 (str (dashcard-url (assoc dashcard :card_id (u/get-id series-card)))))))))))))) + (:status (http/client :get 202 (str (dashcard-url (assoc dashcard :card_id (u/the-id series-card)))))))))))))) ;;; ------------------------------- GET /api/embed/card/:token/field/:field/values nil -------------------------------- @@ -515,12 +524,12 @@ (class Card) (str "card/" (card-token card-or-dashboard)) (class Dashboard) (str "dashboard/" (dash-token card-or-dashboard))) "/field/" - (u/get-id field-or-id) + (u/the-id field-or-id) "/values")) (defn- do-with-embedding-enabled-and-temp-card-referencing {:style/indent 2} [table-kw field-kw f] (with-embedding-enabled-and-new-secret-key - (tt/with-temp Card [card (assoc (public-test/mbql-card-referencing table-kw field-kw) + (mt/with-temp Card [card (assoc (public-test/mbql-card-referencing table-kw field-kw) :enable_embedding true)] (f card)))) @@ -553,24 +562,24 @@ (deftest endpoint-should-fail-if-embedding-is-disabled (is (= "Embedding is not enabled." (with-embedding-enabled-and-temp-card-referencing :venues :name [card] - (tu/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding false] (http/client :get 400 (field-values-url card (mt/id :venues :name)))))))) (deftest embedding-not-enabled-message (is (= "Embedding is not enabled for this object." (with-embedding-enabled-and-temp-card-referencing :venues :name [card] - (db/update! Card (u/get-id card) :enable_embedding false) + (db/update! Card (u/the-id card) :enable_embedding false) (http/client :get 400 (field-values-url card (mt/id :venues :name))))))) ;;; ----------------------------- GET /api/embed/dashboard/:token/field/:field/values nil ----------------------------- (defn- do-with-embedding-enabled-and-temp-dashcard-referencing {:style/indent 2} [table-kw field-kw f] (with-embedding-enabled-and-new-secret-key - (tt/with-temp* [Dashboard [dashboard {:enable_embedding true}] + (mt/with-temp* [Dashboard [dashboard {:enable_embedding true}] Card [card (public-test/mbql-card-referencing table-kw field-kw)] - DashboardCard [dashcard {:dashboard_id (u/get-id dashboard) - :card_id (u/get-id card) - :parameter_mappings [{:card_id (u/get-id card) + DashboardCard [dashcard {:dashboard_id (u/the-id dashboard) + :card_id (u/the-id card) + :parameter_mappings [{:card_id (u/the-id card) :target [:dimension [:field (mt/id table-kw field-kw) nil]]}]}]] @@ -606,7 +615,7 @@ (deftest field-values-endpoint-should-fail-if-embedding-is-disabled (is (= "Embedding is not enabled." (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] - (tu/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding false] (http/client :get 400 (field-values-url dashboard (mt/id :venues :name)))))))) @@ -614,7 +623,7 @@ (deftest endpoint-should-fail-if-embedding-is-disabled-for-the-dashboard (is (= "Embedding is not enabled for this object." (with-embedding-enabled-and-temp-dashcard-referencing :venues :name [dashboard] - (db/update! Dashboard (u/get-id dashboard) :enable_embedding false) + (db/update! Dashboard (u/the-id dashboard) :enable_embedding false) (http/client :get 400 (field-values-url dashboard (mt/id :venues :name))))))) @@ -625,8 +634,8 @@ (condp instance? card-or-dashboard (class Card) (str "card/" (card-token card-or-dashboard)) (class Dashboard) (str "dashboard/" (dash-token card-or-dashboard))) - "/field/" (u/get-id field-or-id) - "/search/" (u/get-id search-field-or-id))) + "/field/" (u/the-id field-or-id) + "/search/" (u/the-id search-field-or-id))) (deftest field-search-test (testing @@ -641,13 +650,13 @@ :value "33 T")))) (testing "Endpoint should fail if embedding is disabled" - (tu/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding false] (is (= "Embedding is not enabled." (http/client :get 400 (field-search-url object (mt/id :venues :id) (mt/id :venues :name)) :value "33 T"))))) (testing "Endpoint should fail if embedding is disabled for the object" - (db/update! model (u/get-id object) :enable_embedding false) + (db/update! model (u/the-id object) :enable_embedding false) (is (= "Embedding is not enabled for this object." (http/client :get 400 (field-search-url object (mt/id :venues :id) (mt/id :venues :name)) :value "33 T")))))] @@ -668,8 +677,8 @@ (condp instance? card-or-dashboard (class Card) (str "card/" (card-token card-or-dashboard)) (class Dashboard) (str "dashboard/" (dash-token card-or-dashboard))) - "/field/" (u/get-id field-or-id) - "/remapping/" (u/get-id remapped-field-or-id))) + "/field/" (u/the-id field-or-id) + "/remapping/" (u/the-id remapped-field-or-id))) (deftest field-remapping-test (letfn [(tests [model object] @@ -684,13 +693,13 @@ :value "10")))) (testing " ...or if embedding is disabled" - (tu/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding false] (is (= "Embedding is not enabled." (http/client :get 400 (field-remapping-url object (mt/id :venues :id) (mt/id :venues :name)) :value "10"))))) (testing " ...or if embedding is disabled for the Card/Dashboard" - (db/update! model (u/get-id object) :enable_embedding false) + (db/update! model (u/the-id object) :enable_embedding false) (is (= "Embedding is not enabled for this object." (http/client :get 400 (field-remapping-url object (mt/id :venues :id) (mt/id :venues :name)) :value "10")))))] @@ -882,7 +891,7 @@ (mt/dataset sample-dataset (testing "GET /api/embed/pivot/card/:token/query" (testing "check that the endpoint doesn't work if embedding isn't enabled" - (tu/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding false] (with-new-secret-key (with-temp-card [card (pivots/pivot-card)] (is (= "Embedding is not enabled." @@ -912,7 +921,7 @@ (defn- pivot-dashcard-url [dashcard & [additional-token-params]] (str "embed/pivot/dashboard/" (dash-token (:dashboard_id dashcard) additional-token-params) - "/dashcard/" (u/get-id dashcard) + "/dashcard/" (u/the-id dashcard) "/card/" (:card_id dashcard))) (deftest pivot-dashcard-success-test @@ -930,7 +939,7 @@ (deftest pivot-dashcard-embedding-disabled-test (mt/dataset sample-dataset - (tu/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding false] (with-new-secret-key (with-temp-dashcard [dashcard {:card (pivots/pivot-card)}] (is (= "Embedding is not enabled." diff --git a/test/metabase/api/preview_embed_test.clj b/test/metabase/api/preview_embed_test.clj index 62fdfd3daba53..9ae547bfbd075 100644 --- a/test/metabase/api/preview_embed_test.clj +++ b/test/metabase/api/preview_embed_test.clj @@ -69,8 +69,8 @@ (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-card [card] (testing "It should be possible to run a Card successfully if you jump through the right hoops..." - (is (= (embed-test/successful-query-results) - (mt/user-http-request :crowberto :get 202 (card-query-url card))))) + (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." @@ -95,9 +95,9 @@ (mt/user-http-request :crowberto :get 400 (card-query-url card {:_embedding_params {:abc "locked"}}))))) (testing "if `:locked` param is supplied, request should succeed" - (is (= (embed-test/successful-query-results) - (mt/user-http-request :crowberto :get 202 (card-query-url card {:_embedding_params {:abc "locked"} - :params {:abc 100}}))))) + (embed-test/test-query-results + (mt/user-http-request :crowberto :get 202 (card-query-url card {:_embedding_params {:abc "locked"} + :params {:abc 100}})))) (testing "if `:locked` parameter is present in URL params, request should fail" (is (= "You can only specify a value for :abc in the JWT." @@ -132,13 +132,13 @@ "?abc=200"))))) (testing "If an `:enabled` param is present in the JWT, that's ok" - (is (= (embed-test/successful-query-results) - (mt/user-http-request :crowberto :get 202 (card-query-url card {:_embedding_params {:abc "enabled"} - :params {:abc "enabled"}}))))) + (embed-test/test-query-results + (mt/user-http-request :crowberto :get 202 (card-query-url card {:_embedding_params {:abc "enabled"} + :params {:abc "enabled"}})))) (testing "If an `:enabled` param is present in URL params but *not* the JWT, that's ok" - (is (= (embed-test/successful-query-results) - (mt/user-http-request :crowberto :get 202 (str (card-query-url card {:_embedding_params {:abc "enabled"}}) - "?abc=200")))))))))) + (embed-test/test-query-results + (mt/user-http-request :crowberto :get 202 (str (card-query-url card {:_embedding_params {:abc "enabled"}}) + "?abc=200"))))))))) ;;; ------------------------------------ GET /api/preview_embed/dashboard/:token ------------------------------------- @@ -197,8 +197,8 @@ (embed-test/with-embedding-enabled-and-new-secret-key (embed-test/with-temp-dashcard [dashcard] (testing "It should be possible to run a Card successfully if you jump through the right hoops..." - (is (= (embed-test/successful-query-results) - (mt/user-http-request :crowberto :get 202 (dashcard-url dashcard))))) + (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." @@ -221,17 +221,17 @@ (testing "check that if embedding is enabled globally fail if the token is missing a `:locked` parameter" (is (= "You must specify a value for :abc in the JWT." (mt/user-http-request :crowberto :get 400 (dashcard-url dashcard - {:_embedding_params {:abc "locked"}}))))) + {:_embedding_params {:abc "locked"}}))))) (testing "If `:locked` param is supplied, request should succeed" - (is (= (embed-test/successful-query-results) - (mt/user-http-request :crowberto :get 202 (dashcard-url dashcard - {:_embedding_params {:abc "locked"}, :params {:abc 100}}))))) + (embed-test/test-query-results + (mt/user-http-request :crowberto :get 202 + (dashcard-url dashcard {:_embedding_params {:abc "locked"}, :params {:abc 100}})))) (testing "If `:locked` parameter is present in URL params, request should fail" (is (= "You can only specify a value for :abc in the JWT." (mt/user-http-request :crowberto :get 400 (str (dashcard-url dashcard - {:_embedding_params {:abc "locked"}, :params {:abc 100}}) + {:_embedding_params {:abc "locked"}, :params {:abc 100}}) "?abc=200")))))))))) (deftest dashcard-disabled-params-test @@ -261,14 +261,14 @@ "?abc=200"))))) (testing "If an `:enabled` param is present in the JWT, that's ok" - (is (= (embed-test/successful-query-results) - (mt/user-http-request :crowberto :get 202 (dashcard-url dashcard {:_embedding_params {:abc "enabled"} - :params {:abc 100}}))))) + (embed-test/test-query-results + (mt/user-http-request :crowberto :get 202 (dashcard-url dashcard {:_embedding_params {:abc "enabled"} + :params {:abc 100}})))) (testing "If an `:enabled` param is present in URL params but *not* the JWT, that's ok" - (is (= (embed-test/successful-query-results) - (mt/user-http-request :crowberto :get 202 (str (dashcard-url dashcard {:_embedding_params {:abc "enabled"}}) - "?abc=200")))))))))) + (embed-test/test-query-results + (mt/user-http-request :crowberto :get 202 (str (dashcard-url dashcard {:_embedding_params {:abc "enabled"}}) + "?abc=200"))))))))) (deftest dashcard-editable-query-params-test (testing (str "Check that editable query params work correctly and keys get coverted from strings to keywords, even " diff --git a/test/metabase/api/task_test.clj b/test/metabase/api/task_test.clj index 510bc8303945c..c765bc73f85d8 100644 --- a/test/metabase/api/task_test.clj +++ b/test/metabase/api/task_test.clj @@ -1,12 +1,10 @@ (ns metabase.api.task-test - (:require [expectations :refer :all] + (:require [clojure.test :refer :all] [java-time :as t] [metabase.models.task-history :refer [TaskHistory]] - [metabase.test.data.users :as users] - [metabase.test.util :as tu] + [metabase.test :as mt] [metabase.util :as u] - [toucan.db :as db] - [toucan.util.test :as tt])) + [toucan.db :as db])) (def ^:private default-task-history {:id true, :db_id true, :started_at true, :ended_at true, :duration 10, :task_details nil}) @@ -16,103 +14,96 @@ via the GET `/` endpoint, will return in reverse order from how this function returns the task history maps." [n] (let [now (t/zoned-date-time) - task-names (repeatedly n tu/random-name)] + task-names (repeatedly n mt/random-name)] (map-indexed (fn [idx task-name] {:task task-name :started_at now :ended_at (t/plus now (t/seconds idx))}) task-names))) -;; Only superusers can query for TaskHistory -(expect - "You don't have permissions to do that." - ((users/user->client :rasta) :get 403 "task/")) +(deftest list-perms-test + (testing "Only superusers can query for TaskHistory" + (is (= "You don't have permissions to do that." + (mt/user-http-request :rasta :get 403 "task/"))))) -;; Superusers can query TaskHistory, should return DB results -(let [[task-hist-1 task-hist-2] (generate-tasks 2) - task-hist-1 (assoc task-hist-1 :duration 100) - task-hist-2 (assoc task-hist-1 :duration 200 :task_details {:some "complex", :data "here"}) - task-names (set (map :task [task-hist-1 task-hist-2]))] - (expect - (set (map (fn [task-hist] - (merge default-task-history (select-keys task-hist [:task :duration :task_details]))) - [task-hist-2 task-hist-1])) - (tt/with-temp* [TaskHistory [task-1 task-hist-1] - TaskHistory [task-2 task-hist-2]] - (set (for [result (:data ((users/user->client :crowberto) :get 200 "task/")) - :when (contains? task-names (:task result))] - (tu/boolean-ids-and-timestamps result)))))) +(deftest list-test + (testing "Superusers can query TaskHistory, should return DB results" + (let [[task-hist-1 task-hist-2] (generate-tasks 2) + task-hist-1 (assoc task-hist-1 :duration 100) + task-hist-2 (assoc task-hist-1 :duration 200 :task_details {:some "complex", :data "here"}) + task-names (set (map :task [task-hist-1 task-hist-2]))] + (mt/with-temp* [TaskHistory [task-1 task-hist-1] + TaskHistory [task-2 task-hist-2]] + (is (= (set (map (fn [task-hist] + (merge default-task-history (select-keys task-hist [:task :duration :task_details]))) + [task-hist-2 task-hist-1])) + (set (for [result (:data (mt/user-http-request :crowberto :get 200 "task/")) + :when (contains? task-names (:task result))] + (mt/boolean-ids-and-timestamps result))))))))) -;; Multiple results should be sorted via `:ended_at`. Below creates two tasks, the second one has a later `:ended_at` -;; date and should be returned first -(let [[task-hist-1 task-hist-2 :as task-histories] (generate-tasks 2) - task-names (set (map :task task-histories))] - (expect - (map (fn [{:keys [task]}] - (assoc default-task-history :task task)) - [task-hist-2 task-hist-1]) - (tt/with-temp* [TaskHistory [task-1 task-hist-1] - TaskHistory [task-2 task-hist-2]] - (for [result (:data ((users/user->client :crowberto) :get 200 "task/")) - :when (contains? task-names (:task result))] - (tu/boolean-ids-and-timestamps result))))) +(deftest sort-by-ended-at-test + (testing (str "Multiple results should be sorted via `:ended_at`. Below creates two tasks, the second one has a " + "later `:ended_at` date and should be returned first") + (let [[task-hist-1 task-hist-2 :as task-histories] (generate-tasks 2) + task-names (set (map :task task-histories))] + (mt/with-temp* [TaskHistory [task-1 task-hist-1] + TaskHistory [task-2 task-hist-2]] + (is (= (map (fn [{:keys [task]}] + (assoc default-task-history :task task)) + [task-hist-2 task-hist-1]) + (for [result (:data (mt/user-http-request :crowberto :get 200 "task/")) + :when (contains? task-names (:task result))] + (mt/boolean-ids-and-timestamps result)))))))) -;; Should fail when only including a limit -(expect - "When including a limit, an offset must also be included." - ((users/user->client :crowberto) :get 400 "task/" :limit 100)) +(deftest limit-param-test + (testing "Should fail when only including a limit" + (is (= "When including a limit, an offset must also be included." + (mt/user-http-request :crowberto :get 400 "task/" :limit 100)))) -;; Should fail when only including an offset -(expect - "When including an offset, a limit must also be included." - ((users/user->client :crowberto) :get 400 "task/" :offset 100)) + (testing "Should fail when only including an offset" + (is (= "When including an offset, a limit must also be included." + (mt/user-http-request :crowberto :get 400 "task/" :offset 100)))) -;; Check that we don't support a 0 limit, which wouldn't make sense -(expect - {:errors {:limit "value may be nil, or if non-nil, value must be a valid integer greater than zero."}} - ((users/user->client :crowberto) :get 400 "task/" :limit 0 :offset 100)) + (testing "Check that we don't support a 0 limit, which wouldn't make sense" + (is (= {:errors {:limit "value may be nil, or if non-nil, value must be a valid integer greater than zero."}} + (mt/user-http-request :crowberto :get 400 "task/" :limit 0 :offset 100))))) -;; Check that paging information is applied when provided and included in the response -(let [[task-hist-1 task-hist-2 task-hist-3 task-hist-4] (generate-tasks 4)] - (expect - [{:total 4, :limit 2, :offset 0 - :data (map (fn [{:keys [task]}] - (assoc default-task-history :task task)) - [task-hist-4 task-hist-3])} - {:total 4, :limit 2, :offset 2 - :data (map (fn [{:keys [task]}] - (assoc default-task-history :task task)) - [task-hist-2 task-hist-1])}] - (do - (db/delete! TaskHistory) - (tt/with-temp* [TaskHistory [task-1 task-hist-1] +(deftest paging-test + (testing "Check that paging information is applied when provided and included in the response" + (db/delete! TaskHistory) + (let [[task-hist-1 task-hist-2 task-hist-3 task-hist-4] (generate-tasks 4)] + (mt/with-temp* [TaskHistory [task-1 task-hist-1] TaskHistory [task-2 task-hist-2] TaskHistory [task-3 task-hist-3] TaskHistory [task-4 task-hist-4]] - (map tu/boolean-ids-and-timestamps - [((users/user->client :crowberto) :get 200 "task/" :limit 2 :offset 0) - ((users/user->client :crowberto) :get 200 "task/" :limit 2 :offset 2)]))))) + (is (= {:total 4, :limit 2, :offset 0 + :data (map (fn [{:keys [task]}] + (assoc default-task-history :task task)) + [task-hist-4 task-hist-3])} + (mt/boolean-ids-and-timestamps + (mt/user-http-request :crowberto :get 200 "task/" :limit 2 :offset 0)))) + (is (= {:total 4, :limit 2, :offset 2 + :data (map (fn [{:keys [task]}] + (assoc default-task-history :task task)) + [task-hist-2 task-hist-1])} + (mt/boolean-ids-and-timestamps + (mt/user-http-request :crowberto :get 200 "task/" :limit 2 :offset 2)))))))) -;; Only superusers can query for TaskHistory -(expect - "You don't have permissions to do that." - ((users/user->client :rasta) :get 403 "task/")) +(deftest not-found-test + (testing "Superusers querying for a TaskHistory that doesn't exist will get a 404" + (is (= "Not found." + (mt/user-http-request :crowberto :get 404 (format "task/%s" Integer/MAX_VALUE)))))) -;; Regular users can't query for a specific TaskHistory -(expect - "You don't have permissions to do that." - (tt/with-temp TaskHistory [task] - ((users/user->client :rasta) :get 403 (format "task/%s" (u/get-id task))))) +(deftest fetch-perms-test + (testing "Regular users can't query for a specific TaskHistory" + (mt/with-temp TaskHistory [task] + (is (= "You don't have permissions to do that." + (mt/user-http-request :rasta :get 403 (format "task/%s" (u/the-id task)))))))) -;; Superusers querying for a TaskHistory that doesn't exist will get a 404 -(expect - "Not found." - ((users/user->client :crowberto) :get 404 (format "task/%s" Integer/MAX_VALUE))) - -;; Superusers querying for specific TaskHistory will get that task info -(expect - (merge default-task-history {:task "Test Task", :duration 100}) - (tt/with-temp TaskHistory [task {:task "Test Task" - :duration 100}] - (tu/boolean-ids-and-timestamps - ((users/user->client :crowberto) :get 200 (format "task/%s" (u/get-id task)))))) +(deftest fetch-test + (testing "Superusers querying for specific TaskHistory will get that task info" + (mt/with-temp TaskHistory [task {:task "Test Task" + :duration 100}] + (is (= (merge default-task-history {:task "Test Task", :duration 100}) + (mt/boolean-ids-and-timestamps + (mt/user-http-request :crowberto :get 200 (format "task/%s" (u/the-id task))))))))) diff --git a/test/metabase/api/util_test.clj b/test/metabase/api/util_test.clj index db88e76d3acab..d7a8b606e7300 100644 --- a/test/metabase/api/util_test.clj +++ b/test/metabase/api/util_test.clj @@ -1,19 +1,18 @@ (ns metabase.api.util-test "Tests for /api/util" - (:require [expectations :refer :all] - [metabase.http-client :refer [client]])) + (:require [clojure.test :refer :all] + [metabase.test :as mt])) +(deftest password-check-test + (testing "POST /api/util/password_check" + (testing "Test for required params" + (is (= {:errors {:password "Insufficient password strength"}} + (mt/client :post 400 "util/password_check" {})))) -;; ## POST /api/util/password_check + (testing "Test complexity check" + (is (= {:errors {:password "Insufficient password strength"}} + (mt/client :post 400 "util/password_check" {:password "blah"})))) -;; Test for required params -(expect {:errors {:password "Insufficient password strength"}} - (client :post 400 "util/password_check" {})) - -;; Test complexity check -(expect {:errors {:password "Insufficient password strength"}} - (client :post 400 "util/password_check" {:password "blah"})) - -;; Should be a valid password -(expect {:valid true} - (client :post 200 "util/password_check" {:password "something1"})) + (testing "Should be a valid password" + (is (= {:valid true} + (mt/client :post 200 "util/password_check" {:password "something1"})))))) diff --git a/test/metabase/async/api_response_test.clj b/test/metabase/async/api_response_test.clj index 3f84bb5e59b25..3c8a10d2e2efb 100644 --- a/test/metabase/async/api_response_test.clj +++ b/test/metabase/async/api_response_test.clj @@ -2,7 +2,6 @@ (:require [cheshire.core :as json] [clojure.core.async :as a] [clojure.test :refer :all] - [expectations :refer [expect]] [metabase.async.api-response :as async-response] [metabase.test.util.async :as tu.async] [ring.core.protocols :as ring.protocols] @@ -55,41 +54,46 @@ ;;; ------------------------------ Normal responses: message sent to the input channel ------------------------------- -;; check that response is actually written to the output stream -(expect - {:success true} - (tu.async/with-chans [input-chan] - (with-response [{:keys [os os-closed-chan]} input-chan] - (a/>!! input-chan {:success true}) - (wait-for-close os-closed-chan) - (os->response os)))) - -;; when we send a single message to the input channel, it should get closed automatically by the async code -(expect - (tu.async/with-chans [input-chan] - (with-response [{:keys [os-closed-chan]} input-chan] - ;; send the result to the input channel - (a/>!! input-chan {:success true}) - (wait-for-close os-closed-chan) - ;; now see if input-chan is closed - (wait-for-close input-chan)))) - -;; when we send a message to the input channel, output-chan should *also* get closed -(expect - (tu.async/with-chans [input-chan] - (with-response [{:keys [os-closed-chan output-chan]} input-chan] - ;; send the result to the input channel - (a/>!! input-chan {:success true}) - (wait-for-close os-closed-chan) - ;; now see if output-chan is closed - (wait-for-close output-chan)))) - -;; ...and the output-stream should be closed as well -(expect - (tu.async/with-chans [input-chan] - (with-response [{:keys [os-closed-chan]} input-chan] - (a/>!! input-chan {:success true}) - (wait-for-close os-closed-chan)))) +(deftest write-response-to-output-stream-test + (testing "check that response is actually written to the output stream" + (tu.async/with-chans [input-chan] + (with-response [{:keys [os os-closed-chan]} input-chan] + (a/>!! input-chan {:success true}) + (wait-for-close os-closed-chan) + (is (= {:success true} + (os->response os))))))) + +(deftest close-input-channel-test + (testing "when we send a single message to the input channel, it should get closed automatically by the async code" + (tu.async/with-chans [input-chan] + (with-response [{:keys [os-closed-chan]} input-chan] + ;; send the result to the input channel + (a/>!! input-chan {:success true}) + (is (= true + (wait-for-close os-closed-chan))) + ;; now see if input-chan is closed + (is (= true + (wait-for-close input-chan))))))) + +(deftest close-output-channel-test + (testing "when we send a message to the input channel, output-chan should *also* get closed" + (tu.async/with-chans [input-chan] + (with-response [{:keys [os-closed-chan output-chan]} input-chan] + ;; send the result to the input channel + (a/>!! input-chan {:success true}) + (is (= true + (wait-for-close os-closed-chan))) + ;; now see if output-chan is closed + (is (= true + (wait-for-close output-chan))))))) + +(deftest close-output-stream-test + (testing "...and the output-stream should be closed as well" + (tu.async/with-chans [input-chan] + (with-response [{:keys [os-closed-chan]} input-chan] + (a/>!! input-chan {:success true}) + (is (= true + (wait-for-close os-closed-chan))))))) ;;; ----------------------------------------- Input-chan closed unexpectedly ----------------------------------------- @@ -119,29 +123,33 @@ ;;; ------------------------------ Output-chan closed early (i.e. API request canceled) ------------------------------ -;; If output-channel gets closed (presumably because the API request is canceled), input-chan should also get closed -(expect - (tu.async/with-chans [input-chan] - (with-response [{:keys [output-chan]} input-chan] - (a/close! output-chan) - (wait-for-close input-chan)))) - -;; if output chan gets closed, output-stream should also get closed -(expect - (tu.async/with-chans [input-chan] - (with-response [{:keys [output-chan os-closed-chan]} input-chan] - (a/close! output-chan) - (wait-for-close os-closed-chan)))) - -;; we shouldn't bother writing anything to the output stream if output-chan is closed because it should already be -;; closed -(expect - nil - (tu.async/with-chans [input-chan] - (with-response [{:keys [output-chan os os-closed-chan]} input-chan] - (a/close! output-chan) - (wait-for-close os-closed-chan) - (os->response os)))) +(deftest close-input-chan-when-output-chan-gets-closed-test + (testing (str "If output-channel gets closed (presumably because the API request is canceled), input-chan should " + "also get closed") + (tu.async/with-chans [input-chan] + (with-response [{:keys [output-chan]} input-chan] + (a/close! output-chan) + (is (= true + (wait-for-close input-chan))))))) + +(deftest close-output-stream-when-output-chan-gets-closed-test + (testing "if output chan gets closed, output-stream should also get closed" + (tu.async/with-chans [input-chan] + (with-response [{:keys [output-chan os-closed-chan]} input-chan] + (a/close! output-chan) + (is (= true + (wait-for-close os-closed-chan))))))) + +(deftest dont-write-to-output-stream-when-closed-test + (testing (str "we shouldn't bother writing anything to the output stream if output-chan is closed because it should " + "already be closed") + (tu.async/with-chans [input-chan] + (with-response [{:keys [output-chan os os-closed-chan]} input-chan] + (a/close! output-chan) + (is (= true + (wait-for-close os-closed-chan))) + (is (= nil + (os->response os))))))) ;;; --------------------------------------- input chan message is an Exception --------------------------------------- diff --git a/test/metabase/automagic_dashboards/rules_test.clj b/test/metabase/automagic_dashboards/rules_test.clj index c327a738cfc6c..25ba1a8e69678 100644 --- a/test/metabase/automagic_dashboards/rules_test.clj +++ b/test/metabase/automagic_dashboards/rules_test.clj @@ -1,31 +1,43 @@ (ns metabase.automagic-dashboards.rules-test - (:require [expectations :refer :all] - [metabase.automagic-dashboards.rules :as rules :refer :all])) + (:require [clojure.test :refer :all] + [metabase.automagic-dashboards.rules :as rules])) -(expect true (ga-dimension? "ga:foo")) -(expect false (ga-dimension? "foo")) +(deftest ga-dimension?-test + (are [x expected] (= expected + (rules/ga-dimension? x)) + "ga:foo" true + "foo" false)) -(expect :foo (#'rules/->type :foo)) -(expect "ga:foo" (#'rules/->type "ga:foo")) -(expect :type/Foo (#'rules/->type "Foo")) +(deftest ->type-test + (are [x expected] (= expected + (#'rules/->type x)) + :foo :foo + "ga:foo" "ga:foo" + "Foo" :type/Foo)) -;; This also tests that all the rules are valid (else there would be nils returned) -(expect (every? some? (get-rules ["table"]))) -(expect (every? some? (get-rules ["metrics"]))) -(expect (every? some? (get-rules ["fields"]))) +(deftest get-rules-test + (testing "This also tests that all the rules are valid (else there would be nils returned)" + (doseq [s ["table" + "metrics" + "fields"]] + (testing s + (is (every? some? (rules/get-rules [s])))))) -(expect (some? (get-rules ["table" "GenericTable" "ByCountry"]))) + (is (some? (rules/get-rules ["table" "GenericTable" "ByCountry"])))) -(expect true (dimension-form? [:dimension "Foo"])) -(expect true (dimension-form? ["dimension" "Foo"])) -(expect true (dimension-form? ["DIMENSION" "Foo"])) -(expect false (dimension-form? 42)) -(expect false (dimension-form? [:baz :bar])) +(deftest dimension-form?-test + (are [x expected] (is (= expected + (rules/dimension-form? x))) + [:dimension "Foo"] true + ["dimension" "Foo"] true + ["DIMENSION" "Foo"] true + 42 false + [:baz :bar] false)) -(expect - ["Foo" "Baz" "Bar"] - (#'rules/collect-dimensions - [{:metrics [{"Foo" {:metric [:sum [:dimension "Foo"]]}} - {"Foo" {:metric [:avg [:dimension "Foo"]]}} - {"Baz" {:metric [:sum ["dimension" "Baz"]]}}]} - [:dimension "Bar"]])) +(deftest collect-dimensions-test + (is (= ["Foo" "Baz" "Bar"] + (#'rules/collect-dimensions + [{:metrics [{"Foo" {:metric [:sum [:dimension "Foo"]]}} + {"Foo" {:metric [:avg [:dimension "Foo"]]}} + {"Baz" {:metric [:sum ["dimension" "Baz"]]}}]} + [:dimension "Bar"]])))) diff --git a/test/metabase/db/spec_test.clj b/test/metabase/db/spec_test.clj index 92d9903b3c942..29e0bafca91a8 100644 --- a/test/metabase/db/spec_test.clj +++ b/test/metabase/db/spec_test.clj @@ -1,5 +1,5 @@ (ns metabase.db.spec-test - (:require [expectations :refer :all] + (:require [clojure.test :refer :all] [metabase.db.spec :as db.spec])) (defn- default-pg-spec [db] @@ -8,48 +8,49 @@ :subname (format "//localhost:5432/%s" db) :OpenSourceSubProtocolOverride true}) -;; Basic minimal config -(expect - (default-pg-spec "metabase") - (db.spec/postgres - {:host "localhost" - :port 5432 - :db "metabase"})) +(deftest basic-test + (testing "Basic minimal config" + (is (= (default-pg-spec "metabase") + (db.spec/postgres + {:host "localhost" + :port 5432 + :db "metabase"}))))) -;; Users that don't specify a `:dbname` (and thus no `:db`) will use the user's default, we should allow that -(expect - (assoc (default-pg-spec "") :dbname nil) - (db.spec/postgres - {:host "localhost" - :port 5432 - :dbname nil - :db nil})) +(deftest defaults-test + (testing (str "Users that don't specify a `:dbname` (and thus no `:db`) will use the user's default, we should " + "allow that") + (is (= (assoc (default-pg-spec "") :dbname nil) + (db.spec/postgres + {:host "localhost" + :port 5432 + :dbname nil + :db nil}))))) -;; We should be tolerant of other random nil values sneaking through -(expect - (assoc (default-pg-spec "") :dbname nil, :somethingrandom nil) - (db.spec/postgres - {:host "localhost" - :port nil - :dbname nil - :db nil - :somethingrandom nil})) +(deftest allow-other-nils-test + (testing "We should be tolerant of other random nil values sneaking through" + (is (= (assoc (default-pg-spec "") :dbname nil, :somethingrandom nil) + (db.spec/postgres + {:host "localhost" + :port nil + :dbname nil + :db nil + :somethingrandom nil}))))) -;; Not specifying any of the values results in defaults -(expect - (default-pg-spec "") - (db.spec/postgres {})) +(deftest postgres-default-values-test + (testing "Not specifying any of the values results in defaults" + (is (= (default-pg-spec "") + (db.spec/postgres {}))))) (defn- default-mysql-spec [db] {:classname "org.mariadb.jdbc.Driver" :subprotocol "mysql" :subname (format "//localhost:3306/%s" db)}) -;; Check that we default to port 3306 for MySQL databases, if `:port` is `nil` -(expect - (assoc (default-mysql-spec "") :dbname nil) - (db.spec/mysql - {:host "localhost" - :port nil - :dbname nil - :db nil})) +(deftest mysql-default-port-test + (testing "Check that we default to port 3306 for MySQL databases, if `:port` is `nil`" + (is (= (assoc (default-mysql-spec "") :dbname nil) + (db.spec/mysql + {:host "localhost" + :port nil + :dbname nil + :db nil}))))) diff --git a/test/metabase/domain_entities/specs_test.clj b/test/metabase/domain_entities/specs_test.clj index e6d923742f290..c3ff46676ed9b 100644 --- a/test/metabase/domain_entities/specs_test.clj +++ b/test/metabase/domain_entities/specs_test.clj @@ -1,8 +1,10 @@ (ns metabase.domain-entities.specs-test - (:require [expectations :refer [expect]] + (:require [clojure.test :refer :all] [metabase.domain-entities.specs :as specs] [schema.core :as s])) -;; All specs should be valid YAML (the parser will raise an exception if not) and conforming to the schema. -(expect - (every? (comp (partial s/validate (var-get #'specs/DomainEntitySpec)) val) @specs/domain-entity-specs)) +(deftest validate-specs-test + (testing "All specs should be valid YAML (the parser will raise an exception if not) and conforming to the schema." + (doseq [[spec-name spec] @specs/domain-entity-specs] + (testing spec-name + (is (not (s/check specs/DomainEntitySpec spec))))))) diff --git a/test/metabase/events/dependencies_test.clj b/test/metabase/events/dependencies_test.clj index d84bfbbc15b0b..749191fbf3b17 100644 --- a/test/metabase/events/dependencies_test.clj +++ b/test/metabase/events/dependencies_test.clj @@ -1,89 +1,86 @@ (ns metabase.events.dependencies-test - (:require [expectations :refer :all] - [metabase.events.dependencies :refer :all] - [metabase.models.card :refer [Card]] - [metabase.models.database :refer [Database]] - [metabase.models.dependency :refer [Dependency]] - [metabase.models.metric :refer [Metric]] - [metabase.models.segment :refer [Segment]] - [metabase.models.table :refer [Table]] - [metabase.test.data :as data] + (:require [clojure.test :refer :all] + [metabase.events.dependencies :as deps] + [metabase.models :refer [Card Database Dependency Metric Segment Table]] + [metabase.test :as mt] [metabase.util :as u] - [toucan.db :as db] - [toucan.util.test :as tt])) + [toucan.db :as db])) (defn- temp-segment [] - {:definition {:database (data/id) - :filter [:= [:field-id (data/id :categories :id)] 1]}}) + {:definition {:database (mt/id) + :filter [:= [:field-id (mt/id :categories :id)] 1]}}) -;; `:card-create` event -(tt/expect-with-temp [Segment [segment-1 (temp-segment)] - Segment [segment-2 (temp-segment)]] - #{{:dependent_on_model "Segment" - :dependent_on_id (u/get-id segment-1)} - {:dependent_on_model "Segment" - :dependent_on_id (u/get-id segment-2)}} - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :categories) - :filter [:and - [:= - (data/id :categories :name) - "Toucan-friendly"] - [:segment (u/get-id segment-1)] - [:segment (u/get-id segment-2)]]}}}] - (process-dependencies-event {:topic :card-create - :item card}) - (set (map (partial into {}) - (db/select [Dependency :dependent_on_model :dependent_on_id] - :model "Card", :model_id (u/get-id card)))))) +(deftest card-create-test + (testing "`:card-create` event" + (mt/with-temp* [Segment [segment-1 (temp-segment)] + Segment [segment-2 (temp-segment)] + Card [card {:dataset_query {:database (mt/id) + :type :query + :query {:source-table (mt/id :categories) + :filter [:and + [:= + (mt/id :categories :name) + "Toucan-friendly"] + [:segment (u/the-id segment-1)] + [:segment (u/the-id segment-2)]]}}}]] + (deps/process-dependencies-event {:topic :card-create + :item card}) + (is (= #{{:dependent_on_model "Segment" + :dependent_on_id (u/the-id segment-1)} + {:dependent_on_model "Segment" + :dependent_on_id (u/the-id segment-2)}} + (set (map (partial into {}) + (db/select [Dependency :dependent_on_model :dependent_on_id] + :model "Card", :model_id (u/the-id card))))))))) -;; `:card-update` event -(expect - [] - (tt/with-temp Card [card {:dataset_query {:database (data/id) - :type :query - :query {:source-table (data/id :categories)}}}] - (process-dependencies-event {:topic :card-create - :item card}) - (db/select [Dependency :dependent_on_model :dependent_on_id], :model "Card", :model_id (u/get-id card)))) +(deftest card-update-test + (testing "`:card-update` event" + (mt/with-temp Card [card {:dataset_query {:database (mt/id) + :type :query + :query {:source-table (mt/id :categories)}}}] + (deps/process-dependencies-event {:topic :card-create + :item card}) + (is (= [] + (db/select [Dependency :dependent_on_model :dependent_on_id], :model "Card", :model_id (u/the-id card))))))) -;; `:metric-create` event -(tt/expect-with-temp [Segment [segment-1 (temp-segment)] - Segment [segment-2 (temp-segment)]] - #{{:dependent_on_model "Segment" - :dependent_on_id (u/get-id segment-1)} - {:dependent_on_model "Segment" - :dependent_on_id (u/get-id segment-2)}} - (tt/with-temp* [Database [{database-id :id}] - Table [{table-id :id} {:db_id database-id}] - Metric [metric {:table_id table-id - :definition {:aggregation [[:count]] - :filter [:and - [:segment (u/get-id segment-1)] - [:segment (u/get-id segment-2)]]}}]] - (process-dependencies-event {:topic :metric-create - :item metric}) - (set (map (partial into {}) - (db/select [Dependency :dependent_on_model :dependent_on_id] - :model "Metric", :model_id (u/get-id metric)))))) +(deftest metric-create-test + (testing "`:metric-create` event" + (mt/with-temp* [Segment [segment-1 (temp-segment)] + Segment [segment-2 (temp-segment)] + Database [{database-id :id}] + Table [{table-id :id} {:db_id database-id}] + Metric [metric {:table_id table-id + :definition {:aggregation [[:count]] + :filter [:and + [:segment (u/the-id segment-1)] + [:segment (u/the-id segment-2)]]}}]] + (deps/process-dependencies-event {:topic :metric-create + :item metric}) + (is (= #{{:dependent_on_model "Segment" + :dependent_on_id (u/the-id segment-1)} + {:dependent_on_model "Segment" + :dependent_on_id (u/the-id segment-2)}} + (set (map (partial into {}) + (db/select [Dependency :dependent_on_model :dependent_on_id] + :model "Metric", :model_id (u/the-id metric))))))))) -;; `:card-update` event -(tt/expect-with-temp [Segment [segment-1 (temp-segment)] - Segment [segment-2 (temp-segment)]] - #{{:dependent_on_model "Segment" - :dependent_on_id (u/get-id segment-1)} - {:dependent_on_model "Segment" - :dependent_on_id (u/get-id segment-2)}} - (tt/with-temp* [Database [{database-id :id}] - Table [{table-id :id} {:db_id database-id}] - Metric [metric {:table_id table-id - :definition {:aggregation ["count"] - :filter ["AND" - ["segment" (u/get-id segment-1)] - ["segment" (u/get-id segment-2)]]}}]] - (process-dependencies-event {:topic :metric-update - :item metric}) - (set (map (partial into {}) - (db/select [Dependency :dependent_on_model :dependent_on_id] - :model "Metric", :model_id (u/get-id metric)))))) +(deftest metric-update-test + (testing "`:metric-update` event" + (mt/with-temp* [Segment [segment-1 (temp-segment)] + Segment [segment-2 (temp-segment)] + Database [{database-id :id}] + Table [{table-id :id} {:db_id database-id}] + Metric [metric {:table_id table-id + :definition {:aggregation ["count"] + :filter ["AND" + ["segment" (u/the-id segment-1)] + ["segment" (u/the-id segment-2)]]}}]] + (deps/process-dependencies-event {:topic :metric-update + :item metric}) + (is (= #{{:dependent_on_model "Segment" + :dependent_on_id (u/the-id segment-1)} + {:dependent_on_model "Segment" + :dependent_on_id (u/the-id segment-2)}} + (set (map (partial into {}) + (db/select [Dependency :dependent_on_model :dependent_on_id] + :model "Metric", :model_id (u/the-id metric))))))))) diff --git a/test/metabase/events/last_login_test.clj b/test/metabase/events/last_login_test.clj index 8c565e77da88d..70eae51f292f0 100644 --- a/test/metabase/events/last_login_test.clj +++ b/test/metabase/events/last_login_test.clj @@ -1,17 +1,16 @@ (ns metabase.events.last-login-test - (:require [expectations :refer :all] - [metabase.events.last-login :refer [process-last-login-event]] + (:require [clojure.test :refer :all] + [metabase.events.last-login :as last-login] [metabase.models.user :refer [User]] - [toucan.util.test :as tt])) + [metabase.test :as mt] + [toucan.db :as db])) -;; `:user-login` event -(expect - {:orig-last-login nil - :upd-last-login false} - (tt/with-temp User [{user-id :id, last-login :last_login}] - (process-last-login-event {:topic :user-login - :item {:user_id user-id - :session_id "doesntmatter"}}) - (let [user (User :id user-id)] - {:orig-last-login last-login - :upd-last-login (nil? (:last_login user))}))) +(deftest user-login-test + (testing "`:user-login` event" + (mt/with-temp User [{user-id :id, last-login :last_login}] + (is (= nil + last-login)) + (last-login/process-last-login-event {:topic :user-login + :item {:user_id user-id + :session_id "doesntmatter"}}) + (is (some? (db/select-one-field :last_login User :id user-id)))))) diff --git a/test/metabase/events/revision_test.clj b/test/metabase/events/revision_test.clj index f59e3c960113e..c9d963bdeee82 100644 --- a/test/metabase/events/revision_test.clj +++ b/test/metabase/events/revision_test.clj @@ -1,337 +1,340 @@ (ns metabase.events.revision-test - (:require [expectations :refer :all] - [metabase.events.revision :refer [process-revision-event!]] - [metabase.models.card :refer [Card]] - [metabase.models.dashboard :refer [Dashboard]] - [metabase.models.dashboard-card :refer [DashboardCard]] - [metabase.models.database :refer [Database]] - [metabase.models.metric :refer [Metric]] - [metabase.models.revision :refer [Revision]] - [metabase.models.segment :refer [Segment]] - [metabase.models.table :refer [Table]] - [metabase.test.data :as data :refer :all] - [metabase.test.data.users :refer :all] + (:require [clojure.test :refer :all] + [metabase.events.revision :as revision] + [metabase.models :refer [Card Dashboard DashboardCard Database Metric Revision Segment Table]] + [metabase.test :as mt] [metabase.util :as u] - [toucan.db :as db] - [toucan.util.test :as tt])) + [toucan.db :as db])) (defn- card-properties "Some default properties for `Cards` for use in tests in this namespace." [] {:display "table" - :dataset_query {:database (data/id) + :dataset_query {:database (mt/id) :type :query - :query {:source-table (data/id :categories)}} + :query {:source-table (mt/id :categories)}} :visualization_settings {} - :creator_id (user->id :crowberto)}) + :creator_id (mt/user->id :crowberto)}) (defn- card->revision-object [card] {:archived false :collection_id nil :collection_position nil :creator_id (:creator_id card) - :database_id (data/id) + :database_id (mt/id) :dataset_query (:dataset_query card) :description nil :display :table :enable_embedding false :embedding_params nil - :id (u/get-id card) + :id (u/the-id card) :made_public_by_id nil :name (:name card) :public_uuid nil :cache_ttl nil :query_type :query - :table_id (data/id :categories) + :table_id (mt/id :categories) :visualization_settings {}}) (defn- dashboard->revision-object [dashboard] {:description nil :name (:name dashboard)}) +(deftest card-create-test + (testing ":card-create" + (mt/with-temp Card [{card-id :id, :as card} (card-properties)][] + (revision/process-revision-event! {:topic :card-create + :item card}) + (is (= {:model "Card" + :model_id card-id + :user_id (mt/user->id :crowberto) + :object (card->revision-object card) + :is_reversion false + :is_creation true} + (mt/derecordize + (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] + :model "Card" + :model_id card-id))))))) -;; :card-create -(tt/expect-with-temp [Card [{card-id :id, :as card} (card-properties)]] - {:model "Card" - :model_id card-id - :user_id (user->id :crowberto) - :object (card->revision-object card) - :is_reversion false - :is_creation true} - (do - (process-revision-event! {:topic :card-create - :item card}) - (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] - :model "Card" - :model_id card-id))) +(deftest card-update-test + (testing ":card-update" + (mt/with-temp Card [{card-id :id, :as card} (card-properties)] + (revision/process-revision-event! {:topic :card-update + :item card}) + (is (= {:model "Card" + :model_id card-id + :user_id (mt/user->id :crowberto) + :object (card->revision-object card) + :is_reversion false + :is_creation false} + (mt/derecordize + (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] + :model "Card" + :model_id card-id))))))) +(deftest dashboard-create-test + (testing ":dashboard-create" + (mt/with-temp Dashboard [{dashboard-id :id, :as dashboard}] + (revision/process-revision-event! {:topic :dashboard-create + :item dashboard}) + (is (= {:model "Dashboard" + :model_id dashboard-id + :user_id (mt/user->id :rasta) + :object (assoc (dashboard->revision-object dashboard) :cards []) + :is_reversion false + :is_creation true} + (mt/derecordize + (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] + :model "Dashboard" + :model_id dashboard-id))))))) -;; :card-update -(tt/expect-with-temp [Card [{card-id :id, :as card} (card-properties)]] - {:model "Card" - :model_id card-id - :user_id (user->id :crowberto) - :object (card->revision-object card) - :is_reversion false - :is_creation false} - (do - (process-revision-event! {:topic :card-update - :item card}) - (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] - :model "Card" - :model_id card-id))) +(deftest dashboard-update-test + (testing ":dashboard-update" + (mt/with-temp Dashboard [{dashboard-id :id, :as dashboard}] + (revision/process-revision-event! {:topic :dashboard-update + :item dashboard}) + (is (= {:model "Dashboard" + :model_id dashboard-id + :user_id (mt/user->id :rasta) + :object (assoc (dashboard->revision-object dashboard) :cards []) + :is_reversion false + :is_creation false} + (mt/derecordize + (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] + :model "Dashboard" + :model_id dashboard-id))))))) +(deftest dashboard-add-cards-test + (testing ":dashboard-add-cards" + (mt/with-temp* [Dashboard [{dashboard-id :id, :as dashboard}] + Card [{card-id :id} (card-properties)] + DashboardCard [dashcard {:card_id card-id, :dashboard_id dashboard-id}]] + (revision/process-revision-event! {:topic :dashboard-add-cards + :item {:id dashboard-id + :actor_id (mt/user->id :rasta) + :dashcards [dashcard]}}) + (is (= {:model "Dashboard" + :model_id dashboard-id + :user_id (mt/user->id :rasta) + :object (assoc (dashboard->revision-object dashboard) :cards [(assoc (select-keys dashcard [:id :card_id :sizeX :sizeY :row :col]) :series [])]) + :is_reversion false + :is_creation false} + (mt/derecordize + (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] + :model "Dashboard" + :model_id dashboard-id))))))) -;; :dashboard-create -(tt/expect-with-temp [Dashboard [{dashboard-id :id, :as dashboard}]] - {:model "Dashboard" - :model_id dashboard-id - :user_id (user->id :rasta) - :object (assoc (dashboard->revision-object dashboard) :cards []) - :is_reversion false - :is_creation true} - (do - (process-revision-event! {:topic :dashboard-create - :item dashboard}) - (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] - :model "Dashboard" - :model_id dashboard-id))) +(deftest dashboard-remove-cards-test + (testing ":dashboard-remove-cards" + (mt/with-temp* [Dashboard [{dashboard-id :id, :as dashboard}] + Card [{card-id :id} (card-properties)] + DashboardCard [dashcard {:card_id card-id, :dashboard_id dashboard-id}]] + (db/simple-delete! DashboardCard, :id (:id dashcard)) + (revision/process-revision-event! {:topic :dashboard-remove-cards + :item {:id dashboard-id + :actor_id (mt/user->id :rasta) + :dashcards [dashcard]}}) + (is (= {:model "Dashboard" + :model_id dashboard-id + :user_id (mt/user->id :rasta) + :object (assoc (dashboard->revision-object dashboard) :cards []) + :is_reversion false + :is_creation false} + (mt/derecordize + (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] + :model "Dashboard" + :model_id dashboard-id))))))) +(deftest dashboard-reposition-cards-test + (testing ":dashboard-reposition-cards" + (mt/with-temp* [Dashboard [{dashboard-id :id, :as dashboard}] + Card [{card-id :id} (card-properties)] + DashboardCard [dashcard {:card_id card-id, :dashboard_id dashboard-id}]] + (db/update! DashboardCard (:id dashcard), :sizeX 4) + (revision/process-revision-event! {:topic :dashboard-reeposition-cards + :item {:id dashboard-id + :actor_id (mt/user->id :crowberto) + :dashcards [(assoc dashcard :sizeX 4)]}}) + (is (= {:model "Dashboard" + :model_id dashboard-id + :user_id (mt/user->id :crowberto) + :object (assoc (dashboard->revision-object dashboard) :cards [{:id (:id dashcard) + :card_id card-id + :sizeX 4 + :sizeY 2 + :row 0 + :col 0 + :series []}]) + :is_reversion false + :is_creation false} + (mt/derecordize + (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] + :model "Dashboard" + :model_id dashboard-id))))))) -;; :dashboard-update -(tt/expect-with-temp [Dashboard [{dashboard-id :id, :as dashboard}]] - {:model "Dashboard" - :model_id dashboard-id - :user_id (user->id :rasta) - :object (assoc (dashboard->revision-object dashboard) :cards []) - :is_reversion false - :is_creation false} - (do - (process-revision-event! {:topic :dashboard-update - :item dashboard}) - (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] - :model "Dashboard" - :model_id dashboard-id))) +(deftest metric-create-test + (testing ":metric-create" + (mt/with-temp* [Database [{database-id :id}] + Table [{:keys [id]} {:db_id database-id}] + Metric [metric {:table_id id, :definition {:a "b"}}]] + (revision/process-revision-event! {:topic :metric-create + :item metric}) + (let [revision (db/select-one [Revision :model :user_id :object :is_reversion :is_creation :message] + :model "Metric" + :model_id (:id metric))] + (is (= {:model "Metric" + :user_id (mt/user->id :rasta) + :object {:name "Toucans in the rainforest" + :description "Lookin' for a blueberry" + :how_is_this_calculated nil + :show_in_getting_started false + :caveats nil + :points_of_interest nil + :archived false + :creator_id (mt/user->id :rasta) + :definition {:a "b"}} + :is_reversion false + :is_creation true + :message nil} + (mt/derecordize + (assoc revision :object (dissoc (:object revision) :id :table_id))))))))) +(deftest metric-update-test + (testing ":metric-update" + (mt/with-temp* [Database [{database-id :id}] + Table [{:keys [id]} {:db_id database-id}] + Metric [metric {:table_id id, :definition {:a "b"}}]] + (revision/process-revision-event! {:topic :metric-update + :item (assoc metric + :actor_id (mt/user->id :crowberto) + :revision_message "updated")}) + (let [revision (db/select-one [Revision :model :user_id :object :is_reversion :is_creation :message] + :model "Metric" + :model_id (:id metric))] + (is (= {:model "Metric" + :user_id (mt/user->id :crowberto) + :object {:name "Toucans in the rainforest" + :description "Lookin' for a blueberry" + :how_is_this_calculated nil + :show_in_getting_started false + :caveats nil + :points_of_interest nil + :archived false + :creator_id (mt/user->id :rasta) + :definition {:a "b"}} + :is_reversion false + :is_creation false + :message "updated"} + (mt/derecordize + (assoc revision :object (dissoc (:object revision) :id :table_id))))))))) -;; :dashboard-add-cards -(tt/expect-with-temp [Dashboard [{dashboard-id :id, :as dashboard}] - Card [{card-id :id} (card-properties)] - DashboardCard [dashcard {:card_id card-id, :dashboard_id dashboard-id}]] - {:model "Dashboard" - :model_id dashboard-id - :user_id (user->id :rasta) - :object (assoc (dashboard->revision-object dashboard) :cards [(assoc (select-keys dashcard [:id :card_id :sizeX :sizeY :row :col]) :series [])]) - :is_reversion false - :is_creation false} - (do - (process-revision-event! {:topic :dashboard-add-cards - :item {:id dashboard-id - :actor_id (user->id :rasta) - :dashcards [dashcard]}}) - (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] - :model "Dashboard" - :model_id dashboard-id))) +(deftest metric-delete-test + (testing ":metric-delete" + (mt/with-temp* [Database [{database-id :id}] + Table [{:keys [id]} {:db_id database-id}] + Metric [metric {:table_id id, :definition {:a "b"}, :archived true}]] + (revision/process-revision-event! {:topic :metric-delete + :item metric}) + (let [revision (db/select-one [Revision :model :user_id :object :is_reversion :is_creation :message] + :model "Metric" + :model_id (:id metric))] + (is (= {:model "Metric" + :user_id (mt/user->id :rasta) + :object {:name "Toucans in the rainforest" + :description "Lookin' for a blueberry" + :how_is_this_calculated nil + :show_in_getting_started false + :caveats nil + :points_of_interest nil + :archived true + :creator_id (mt/user->id :rasta) + :definition {:a "b"}} + :is_reversion false + :is_creation false + :message nil} + (mt/derecordize + (assoc revision :object (dissoc (:object revision) :id :table_id))))))))) -;; :dashboard-remove-cards -(tt/expect-with-temp [Dashboard [{dashboard-id :id, :as dashboard}] - Card [{card-id :id} (card-properties)] - DashboardCard [dashcard {:card_id card-id, :dashboard_id dashboard-id}]] - {:model "Dashboard" - :model_id dashboard-id - :user_id (user->id :rasta) - :object (assoc (dashboard->revision-object dashboard) :cards []) - :is_reversion false - :is_creation false} - (do - (db/simple-delete! DashboardCard, :id (:id dashcard)) - (process-revision-event! {:topic :dashboard-remove-cards - :item {:id dashboard-id - :actor_id (user->id :rasta) - :dashcards [dashcard]}}) - (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] - :model "Dashboard" - :model_id dashboard-id))) +(deftest segment-create-test + (testing ":segment-create" + (mt/with-temp* [Database [{database-id :id}] + Table [{:keys [id]} {:db_id database-id}] + Segment [segment {:table_id id + :definition {:a "b"}}]] + (revision/process-revision-event! {:topic :segment-create + :item segment}) + (let [revision (-> (Revision :model "Segment", :model_id (:id segment)) + (select-keys [:model :user_id :object :is_reversion :is_creation :message]))] + (is (= {:model "Segment" + :user_id (mt/user->id :rasta) + :object {:name "Toucans in the rainforest" + :description "Lookin' for a blueberry" + :show_in_getting_started false + :caveats nil + :points_of_interest nil + :archived false + :creator_id (mt/user->id :rasta) + :definition {:a "b"}} + :is_reversion false + :is_creation true + :message nil} + (mt/derecordize + (assoc revision :object (dissoc (:object revision) :id :table_id))))))))) +(deftest segment-update-test + (testing ":segment-update" + (mt/with-temp* [Database [{database-id :id}] + Table [{:keys [id]} {:db_id database-id}] + Segment [segment {:table_id id + :definition {:a "b"}}]] + (revision/process-revision-event! {:topic :segment-update + :item (assoc segment + :actor_id (mt/user->id :crowberto) + :revision_message "updated")}) + (is (= {:model "Segment" + :user_id (mt/user->id :crowberto) + :object {:name "Toucans in the rainforest" + :description "Lookin' for a blueberry" + :show_in_getting_started false + :caveats nil + :points_of_interest nil + :archived false + :creator_id (mt/user->id :rasta) + :definition {:a "b"}} + :is_reversion false + :is_creation false + :message "updated"} + (mt/derecordize + (update (db/select-one [Revision :model :user_id :object :is_reversion :is_creation :message] + :model "Segment" + :model_id (:id segment)) + :object dissoc :id :table_id))))))) -;; :dashboard-reposition-cards -(tt/expect-with-temp [Dashboard [{dashboard-id :id, :as dashboard}] - Card [{card-id :id} (card-properties)] - DashboardCard [dashcard {:card_id card-id, :dashboard_id dashboard-id}]] - {:model "Dashboard" - :model_id dashboard-id - :user_id (user->id :crowberto) - :object (assoc (dashboard->revision-object dashboard) :cards [{:id (:id dashcard) - :card_id card-id - :sizeX 4 - :sizeY 2 - :row 0 - :col 0 - :series []}]) - :is_reversion false - :is_creation false} - (do - (db/update! DashboardCard (:id dashcard), :sizeX 4) - (process-revision-event! {:topic :dashboard-reeposition-cards - :item {:id dashboard-id - :actor_id (user->id :crowberto) - :dashcards [(assoc dashcard :sizeX 4)]}}) - (db/select-one [Revision :model :model_id :user_id :object :is_reversion :is_creation] - :model "Dashboard" - :model_id dashboard-id))) - - -;; :metric-create -(expect - {:model "Metric" - :user_id (user->id :rasta) - :object {:name "Toucans in the rainforest" - :description "Lookin' for a blueberry" - :how_is_this_calculated nil - :show_in_getting_started false - :caveats nil - :points_of_interest nil - :archived false - :creator_id (user->id :rasta) - :definition {:a "b"}} - :is_reversion false - :is_creation true - :message nil} - (tt/with-temp* [Database [{database-id :id}] - Table [{:keys [id]} {:db_id database-id}] - Metric [metric {:table_id id, :definition {:a "b"}}]] - (process-revision-event! {:topic :metric-create - :item metric}) - - (let [revision (db/select-one [Revision :model :user_id :object :is_reversion :is_creation :message], :model "Metric", :model_id (:id metric))] - (assoc revision :object (dissoc (:object revision) :id :table_id))))) - - -;; :metric-update -(expect - {:model "Metric" - :user_id (user->id :crowberto) - :object {:name "Toucans in the rainforest" - :description "Lookin' for a blueberry" - :how_is_this_calculated nil - :show_in_getting_started false - :caveats nil - :points_of_interest nil - :archived false - :creator_id (user->id :rasta) - :definition {:a "b"}} - :is_reversion false - :is_creation false - :message "updated"} - (tt/with-temp* [Database [{database-id :id}] - Table [{:keys [id]} {:db_id database-id}] - Metric [metric {:table_id id, :definition {:a "b"}}]] - (process-revision-event! {:topic :metric-update - :item (assoc metric - :actor_id (user->id :crowberto) - :revision_message "updated")}) - (let [revision (db/select-one [Revision :model :user_id :object :is_reversion :is_creation :message], :model "Metric", :model_id (:id metric))] - (assoc revision :object (dissoc (:object revision) :id :table_id))))) - - -;; :metric-delete -(expect - {:model "Metric" - :user_id (user->id :rasta) - :object {:name "Toucans in the rainforest" - :description "Lookin' for a blueberry" - :how_is_this_calculated nil - :show_in_getting_started false - :caveats nil - :points_of_interest nil - :archived true - :creator_id (user->id :rasta) - :definition {:a "b"}} - :is_reversion false - :is_creation false - :message nil} - (tt/with-temp* [Database [{database-id :id}] - Table [{:keys [id]} {:db_id database-id}] - Metric [metric {:table_id id, :definition {:a "b"}, :archived true}]] - (process-revision-event! {:topic :metric-delete - :item metric}) - (let [revision (db/select-one [Revision :model :user_id :object :is_reversion :is_creation :message], :model "Metric", :model_id (:id metric))] - (assoc revision :object (dissoc (:object revision) :id :table_id))))) - - -;; :segment-create -(expect - {:model "Segment" - :user_id (user->id :rasta) - :object {:name "Toucans in the rainforest" - :description "Lookin' for a blueberry" - :show_in_getting_started false - :caveats nil - :points_of_interest nil - :archived false - :creator_id (user->id :rasta) - :definition {:a "b"}} - :is_reversion false - :is_creation true - :message nil} - (tt/with-temp* [Database [{database-id :id}] - Table [{:keys [id]} {:db_id database-id}] - Segment [segment {:table_id id - :definition {:a "b"}}]] - (process-revision-event! {:topic :segment-create - :item segment}) - (let [revision (-> (Revision :model "Segment", :model_id (:id segment)) - (select-keys [:model :user_id :object :is_reversion :is_creation :message]))] - (assoc revision :object (dissoc (:object revision) :id :table_id))))) - -;; :segment-update -(expect - {:model "Segment" - :user_id (user->id :crowberto) - :object {:name "Toucans in the rainforest" - :description "Lookin' for a blueberry" - :show_in_getting_started false - :caveats nil - :points_of_interest nil - :archived false - :creator_id (user->id :rasta) - :definition {:a "b"}} - :is_reversion false - :is_creation false - :message "updated"} - (tt/with-temp* [Database [{database-id :id}] - Table [{:keys [id]} {:db_id database-id}] - Segment [segment {:table_id id - :definition {:a "b"}}]] - (process-revision-event! {:topic :segment-update - :item (assoc segment - :actor_id (user->id :crowberto) - :revision_message "updated")}) - (update (db/select-one [Revision :model :user_id :object :is_reversion :is_creation :message], :model "Segment", :model_id (:id segment)) - :object (u/rpartial dissoc :id :table_id)))) - -;; :segment-delete -(expect - {:model "Segment" - :user_id (user->id :rasta) - :object {:name "Toucans in the rainforest" - :description "Lookin' for a blueberry" - :show_in_getting_started false - :caveats nil - :points_of_interest nil - :archived true - :creator_id (user->id :rasta) - :definition {:a "b"}} - :is_reversion false - :is_creation false - :message nil} - (tt/with-temp* [Database [{database-id :id}] - Table [{:keys [id]} {:db_id database-id}] - Segment [segment {:table_id id - :definition {:a "b"} - :archived true}]] - (process-revision-event! {:topic :segment-delete - :item segment}) - (update (db/select-one [Revision :model :user_id :object :is_reversion :is_creation :message], :model "Segment", :model_id (:id segment)) - :object (u/rpartial dissoc :id :table_id)))) +(deftest segment-delete-test + (testing ":segment-delete" + (mt/with-temp* [Database [{database-id :id}] + Table [{:keys [id]} {:db_id database-id}] + Segment [segment {:table_id id + :definition {:a "b"} + :archived true}]] + (revision/process-revision-event! {:topic :segment-delete + :item segment}) + (is (= {:model "Segment" + :user_id (mt/user->id :rasta) + :object {:name "Toucans in the rainforest" + :description "Lookin' for a blueberry" + :show_in_getting_started false + :caveats nil + :points_of_interest nil + :archived true + :creator_id (mt/user->id :rasta) + :definition {:a "b"}} + :is_reversion false + :is_creation false + :message nil} + (mt/derecordize + (update (db/select-one [Revision :model :user_id :object :is_reversion :is_creation :message] + :model "Segment" + :model_id (:id segment)) + :object dissoc :id :table_id))))))) diff --git a/test/metabase/metabot/events_test.clj b/test/metabase/metabot/events_test.clj index b8b0533afc6d4..299694a9e0463 100644 --- a/test/metabase/metabot/events_test.clj +++ b/test/metabase/metabot/events_test.clj @@ -1,23 +1,19 @@ (ns metabase.metabot.events-test (:require [cheshire.core :as json] - [expectations :refer [expect]] + [clojure.test :refer :all] [metabase.metabot.command :as metabot.cmd] [metabase.metabot.events :as metabot.events] [metabase.metabot.slack :as metabot.slack] [metabase.metabot.test-util :as metabot.test.u])) -;; check that things get parsed correctly -(expect - '(show 100) - (#'metabot.events/str->tokens "show 100")) - -(expect - '(show "Birdwatching Hot Spots") - (#'metabot.events/str->tokens "show \"Birdwatching Hot Spots\"")) - -(expect - '(SHOW :wow) - (#'metabot.events/str->tokens "SHOW :wow")) +(deftest str->tokens-test + (testing "check that things get parsed correctly" + (is (= '(show 100) + (#'metabot.events/str->tokens "show 100"))) + (is (= '(show "Birdwatching Hot Spots") + (#'metabot.events/str->tokens "show \"Birdwatching Hot Spots\""))) + (is (= '(SHOW :wow) + (#'metabot.events/str->tokens "SHOW :wow"))))) (defn- handle-slack-event [event] (metabot.test.u/with-slack-messages @@ -26,84 +22,80 @@ (json/generate-string (merge {:type "message", :ts "1001"} event))))) -;; MetaBot shouldn't handle events that aren't of type "message" -(expect - [] - (handle-slack-event {:text "metabot list", :type "not_a_message"})) - -;; MetaBot shouldn't handle "message" events if the subtype is something like "bot_message" -(expect - [] - (handle-slack-event {:text "metabot list", :subtype "bot_message"})) - -;; MetaBot shouldn't handlle events if they were posted after the MetaBot start time -(expect - [] - (handle-slack-event {:text "metabot list", :ts "999"})) - -;; MetaBot shouldn't handle events that don't start with metabot -(expect - [] - (handle-slack-event {:text "metabase list"})) - -;; Make sure the message CHANNEL gets bound correctly! -(expect - '[(post-chat-message! "#she-watch-channel-zero")] - (with-redefs [metabot.cmd/command (fn [& _] @#'metabot.slack/*channel-id*)] - (handle-slack-event {:text "metabot list", :channel "#she-watch-channel-zero"}))) - -;; (but we should allow misspellings like `metaboat` or `meatbot` :scream_cat: -(expect - '[(post-chat-message! "OK")] - (with-redefs [metabot.cmd/command (constantly "OK")] - (handle-slack-event {:text "meatbot list"}))) - -(expect - '[(post-chat-message! "OK")] - (with-redefs [metabot.cmd/command (constantly "OK")] - (handle-slack-event {:text "metaboat list"}))) - -;; If the command function returns a string, we should post that directly as a chat message -(expect - '[(post-chat-message! "(command list)")] - (with-redefs [metabot.cmd/command (fn [& args] (str (cons 'command args)))] - (handle-slack-event {:text "metabot list"}))) - -;; command strings should get parsed correctly as if they were EDN -(expect - [(list - 'post-chat-message! - (str "(command \"class clojure.lang.Symbol symbol\"" - " \"class java.lang.Double 1.0\"" - " \"class java.lang.Long 2\"" - " \"class clojure.lang.Keyword :keyword\"" - " \"class java.lang.String String\")"))] - (with-redefs [metabot.cmd/command (fn [& args] - (str (cons 'command (for [arg args] - (str (class arg) " " arg)))))] - (handle-slack-event {:text "metabot symbol 1.0 2 :keyword \"String\""}))) - -;; if the command function returns something other than a string, we should post that as a code block -(expect - '[(post-chat-message! "```\n(command list)\n```")] - (with-redefs [metabot.cmd/command (fn [& args] - (cons 'command args))] - (metabot.test.u/with-slack-messages - (#'metabot.events/handle-slack-message {:text "metabot list"})))) - -;; if the command function sends stuff async, that should get posted -(expect - '[(post-chat-message! "HERE ARE YOUR RESULTZ" "[attachment]") - (post-chat-message! "Just a second...")] - (with-redefs [metabot.cmd/command (fn [& args] - (metabot.slack/async - (metabot.slack/post-chat-message! "HERE ARE YOUR RESULTZ" "[attachment]")) - "Just a second...")] - (handle-slack-event {:text "metabot list"}))) - -;; if the command function throws an Exception, we should post an 'Uh-oh' message -(expect - '[(post-chat-message! "Uh oh! :cry:\n> Sorry, maybe next time!")] - (with-redefs [metabot.cmd/command (fn [& args] - (throw (Exception. "Sorry, maybe next time!")))] - (handle-slack-event {:text "metabot list"}))) +(deftest ignore-non-message-events-test + (testing "MetaBot shouldn't handle events that aren't of type \"message\"" + (is (= [] + (handle-slack-event {:text "metabot list", :type "not_a_message"}))))) + +(deftest ignore-bot-message-events-test + (testing "MetaBot shouldn't handle \"message\" events if the subtype is something like \"bot_message\"" + (is (= [] + (handle-slack-event {:text "metabot list", :subtype "bot_message"}))))) + +(deftest ignore-old-messages-test + (testing "MetaBot shouldn't handle events if they were posted after the MetaBot start time" + (is (= [] + (handle-slack-event {:text "metabot list", :ts "999"}))))) + +(deftest ignore-non-metabot-events-test + (testing "MetaBot shouldn't handle events that don't start with metabot" + (is (= [] + (handle-slack-event {:text "metabase list"}))))) + +(deftest channel-test + (testing "Make sure the message CHANNEL gets bound correctly!" + (with-redefs [metabot.cmd/command (fn [& _] @#'metabot.slack/*channel-id*)] + (is (= '[(post-chat-message! "#she-watch-channel-zero")] + (handle-slack-event {:text "metabot list", :channel "#she-watch-channel-zero"})))) + + (testing "(but we should allow misspellings like `metaboat` or `meatbot` :scream_cat:" + (with-redefs [metabot.cmd/command (constantly "OK")] + (is (= '[(post-chat-message! "OK")] + (handle-slack-event {:text "meatbot list"}))) + (is (= '[(post-chat-message! "OK")] + (handle-slack-event {:text "metaboat list"}))))))) + +(deftest string-response-test + (testing "If the command function returns a string, we should post that directly as a chat message" + (with-redefs [metabot.cmd/command (fn [& args] (str (cons 'command args)))] + (is (= '[(post-chat-message! "(command list)")] + (handle-slack-event {:text "metabot list"})))))) + +(deftest parse-edn-test + (testing "command strings should get parsed correctly as if they were EDN" + (with-redefs [metabot.cmd/command (fn [& args] + (str (cons 'command (for [arg args] + (str (class arg) " " arg)))))] + (is (= [(list + 'post-chat-message! + (str "(command \"class clojure.lang.Symbol symbol\"" + " \"class java.lang.Double 1.0\"" + " \"class java.lang.Long 2\"" + " \"class clojure.lang.Keyword :keyword\"" + " \"class java.lang.String String\")"))] + (handle-slack-event {:text "metabot symbol 1.0 2 :keyword \"String\""})))))) + +(deftest code-block-response-test + (testing "if the command function returns something other than a string, we should post that as a code block" + (with-redefs [metabot.cmd/command (fn [& args] + (cons 'command args))] + (is (= '[(post-chat-message! "```\n(command list)\n```")] + (metabot.test.u/with-slack-messages + (#'metabot.events/handle-slack-message {:text "metabot list"}))))))) + +(deftest async-test + (testing "if the command function sends stuff async, that should get posted" + (with-redefs [metabot.cmd/command (fn [& args] + (metabot.slack/async + (metabot.slack/post-chat-message! "HERE ARE YOUR RESULTZ" "[attachment]")) + "Just a second...")] + (is (= '[(post-chat-message! "HERE ARE YOUR RESULTZ" "[attachment]") + (post-chat-message! "Just a second...")] + (handle-slack-event {:text "metabot list"})))))) + +(deftest exception-test + (testing "if the command function throws an Exception, we should post an 'Uh-oh' message" + (with-redefs [metabot.cmd/command (fn [& args] + (throw (Exception. "Sorry, maybe next time!")))] + (is (= '[(post-chat-message! "Uh oh! :cry:\n> Sorry, maybe next time!")] + (handle-slack-event {:text "metabot list"})))))) diff --git a/test/metabase/metabot/instance_test.clj b/test/metabase/metabot/instance_test.clj index 943d97c6e620e..4bf9f01fdbf6a 100644 --- a/test/metabase/metabot/instance_test.clj +++ b/test/metabase/metabot/instance_test.clj @@ -1,37 +1,34 @@ (ns metabase.metabot.instance-test - (:require [expectations :refer [expect]] + (:require [clojure.test :refer :all] [metabase.metabot.instance :as metabot.instance] [metabase.util.date-2 :as u.date])) -;; test that if we're not the MetaBot based on Settings, our function to check is working correctly -(expect - false - (do +(deftest am-i-the-metabot-test? + (testing "test that if we're not the MetaBot based on Settings, our function to check is working correctly" (#'metabot.instance/metabot-instance-uuid nil) (#'metabot.instance/metabot-instance-last-checkin nil) - (#'metabot.instance/am-i-the-metabot?))) + (is (= false + (#'metabot.instance/am-i-the-metabot?))))) -;; test that if nobody is currently the MetaBot, we will become the MetaBot -(expect - (do +(deftest become-test-metabot-test + (testing "test that if nobody is currently the MetaBot, we will become the MetaBot" (#'metabot.instance/metabot-instance-uuid nil) (#'metabot.instance/metabot-instance-last-checkin nil) (#'metabot.instance/check-and-update-instance-status!) - (#'metabot.instance/am-i-the-metabot?))) + (is (#'metabot.instance/am-i-the-metabot?)))) -;; test that if nobody has checked in as MetaBot for a while, we will become the MetaBot -(expect - (do +(deftest become-the-metabot-if-no-checkins-test + (testing "test that if nobody has checked in as MetaBot for a while, we will become the MetaBot" (#'metabot.instance/metabot-instance-uuid (str (java.util.UUID/randomUUID))) - (#'metabot.instance/metabot-instance-last-checkin (u.date/add (#'metabot.instance/current-timestamp-from-db) :minute -10)) + (#'metabot.instance/metabot-instance-last-checkin + (u.date/add (#'metabot.instance/current-timestamp-from-db) :minute -10)) (#'metabot.instance/check-and-update-instance-status!) - (#'metabot.instance/am-i-the-metabot?))) + (is (#'metabot.instance/am-i-the-metabot?)))) -;; check that if another instance has checked in recently, we will *not* become the MetaBot -(expect - false - (do +(deftest another-instance-test + (testing "check that if another instance has checked in recently, we will *not* become the MetaBot" (#'metabot.instance/metabot-instance-uuid (str (java.util.UUID/randomUUID))) (#'metabot.instance/metabot-instance-last-checkin (#'metabot.instance/current-timestamp-from-db)) (#'metabot.instance/check-and-update-instance-status!) - (#'metabot.instance/am-i-the-metabot?))) + (is (= false + (#'metabot.instance/am-i-the-metabot?))))) diff --git a/test/metabase/models/dashboard_card_test.clj b/test/metabase/models/dashboard_card_test.clj index 367c2bc468303..ba8bab667cd96 100644 --- a/test/metabase/models/dashboard_card_test.clj +++ b/test/metabase/models/dashboard_card_test.clj @@ -1,16 +1,13 @@ (ns metabase.models.dashboard-card-test (:require [clojure.test :refer :all] - [expectations :refer :all] [metabase.models.card :refer [Card]] [metabase.models.card-test :as card-test] [metabase.models.dashboard :refer [Dashboard]] - [metabase.models.dashboard-card :refer :all] + [metabase.models.dashboard-card :as dashboard-card :refer [DashboardCard]] [metabase.models.dashboard-card-series :refer [DashboardCardSeries]] [metabase.test :as mt] - [metabase.test.data.users :refer :all] [metabase.util :as u] - [toucan.db :as db] - [toucan.util.test :as tt])) + [toucan.db :as db])) (defn remove-ids-and-timestamps [m] (let [f (fn [v] @@ -25,184 +22,180 @@ (= :updated_at k)) [k (f v)]))))) +(deftest retrieve-dashboard-card-test + (testing "retrieve-dashboard-card basic dashcard (no additional series)" + (mt/with-temp* [Dashboard [{dashboard-id :id}] + Card [{card-id :id}] + DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id, :parameter_mappings [{:foo "bar"}]}]] + (is (= {:sizeX 2 + :sizeY 2 + :col 0 + :row 0 + :parameter_mappings [{:foo "bar"}] + :visualization_settings {} + :series []} + (remove-ids-and-timestamps (dashboard-card/retrieve-dashboard-card dashcard-id))))))) -;; retrieve-dashboard-card -;; basic dashcard (no additional series) -(expect - {:sizeX 2 - :sizeY 2 - :col 0 - :row 0 - :parameter_mappings [{:foo "bar"}] - :visualization_settings {} - :series []} - (tt/with-temp* [Dashboard [{dashboard-id :id}] - Card [{card-id :id}] - DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id, :parameter_mappings [{:foo "bar"}]}]] - (remove-ids-and-timestamps (retrieve-dashboard-card dashcard-id)))) - -;; retrieve-dashboard-card -;; dashcard w/ additional series -(expect - {:sizeX 2 - :sizeY 2 - :col 0 - :row 0 - :parameter_mappings [] - :visualization_settings {} - :series [{:name "Additional Series Card 1" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}} - {:name "Additional Series Card 2" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}]} - (tt/with-temp* [Dashboard [{dashboard-id :id}] - Card [{card-id :id}] - Card [{series-id-1 :id} {:name "Additional Series Card 1"}] - Card [{series-id-2 :id} {:name "Additional Series Card 2"}] - DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id}] - DashboardCardSeries [_ {:dashboardcard_id dashcard-id, :card_id series-id-1, :position 0}] - DashboardCardSeries [_ {:dashboardcard_id dashcard-id, :card_id series-id-2, :position 1}]] - (remove-ids-and-timestamps (retrieve-dashboard-card dashcard-id)))) - +(deftest retrieve-dashboard-card-with-additional-series-test + (testing "retrieve-dashboard-card dashcard w/ additional series" + (mt/with-temp* [Dashboard [{dashboard-id :id}] + Card [{card-id :id}] + Card [{series-id-1 :id} {:name "Additional Series Card 1"}] + Card [{series-id-2 :id} {:name "Additional Series Card 2"}] + DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id}] + DashboardCardSeries [_ {:dashboardcard_id dashcard-id, :card_id series-id-1, :position 0}] + DashboardCardSeries [_ {:dashboardcard_id dashcard-id, :card_id series-id-2, :position 1}]] + (is (= {:sizeX 2 + :sizeY 2 + :col 0 + :row 0 + :parameter_mappings [] + :visualization_settings {} + :series [{:name "Additional Series Card 1" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}} + {:name "Additional Series Card 2" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}]} + (remove-ids-and-timestamps (dashboard-card/retrieve-dashboard-card dashcard-id))))))) -;; update-dashboard-card-series! -(expect - [#{} - #{"card1"} - #{"card2"} - #{"card2" "card1"} - #{"card1" "card3"}] - (tt/with-temp* [Dashboard [{dashboard-id :id} {:name "Test Dashboard" - :creator_id (user->id :rasta)}] +(deftest update-dashboard-card-series!-test + (mt/with-temp* [Dashboard [{dashboard-id :id} {:name "Test Dashboard" + :creator_id (mt/user->id :rasta)}] Card [{card-id :id}] DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id}] Card [{card-id-1 :id} {:name "card1"}] Card [{card-id-2 :id} {:name "card2"}] Card [{card-id3 :id} {:name "card3"}]] (let [upd-series (fn [series] - (update-dashboard-card-series! {:id dashcard-id} series) + (dashboard-card/update-dashboard-card-series! {:id dashcard-id} series) (set (for [card-id (db/select-field :card_id DashboardCardSeries, :dashboardcard_id dashcard-id)] (db/select-one-field :name Card, :id card-id))))] - [(upd-series []) - (upd-series [card-id-1]) - (upd-series [card-id-2]) - (upd-series [card-id-2 card-id-1]) - (upd-series [card-id-1 card-id3])]))) + (is (= #{} + (upd-series []))) + (is (= #{"card1"} + (upd-series [card-id-1]))) + (is (= #{"card2"} + (upd-series [card-id-2]))) + (is (= #{"card1" "card2"} + (upd-series [card-id-2 card-id-1]))) + (is (= #{"card3" "card1"} + (upd-series [card-id-1 card-id3])))))) +(deftest create-dashboard-card!-test + (testing "create-dashboard-card! simple example with a single card" + (mt/with-temp* [Dashboard [{dashboard-id :id}] + Card [{card-id :id} {:name "Test Card"}]] + (let [dashboard-card (dashboard-card/create-dashboard-card! + {:creator_id (mt/user->id :rasta) + :dashboard_id dashboard-id + :card_id card-id + :sizeX 4 + :sizeY 3 + :row 1 + :col 1 + :parameter_mappings [{:foo "bar"}] + :visualization_settings {} + :series [card-id]})] + (testing "return value from function" + (is (= {:sizeX 4 + :sizeY 3 + :col 1 + :row 1 + :parameter_mappings [{:foo "bar"}] + :visualization_settings {} + :series [{:name "Test Card" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}]} + (remove-ids-and-timestamps dashboard-card)))) + (testing "validate db captured everything" + (is (= {:sizeX 4 + :sizeY 3 + :col 1 + :row 1 + :parameter_mappings [{:foo "bar"}] + :visualization_settings {} + :series [{:name "Test Card" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}]} + (remove-ids-and-timestamps (dashboard-card/retrieve-dashboard-card (:id dashboard-card)))))))))) -;; create-dashboard-card! -;; simple example with a single card -(expect - [{:sizeX 4 - :sizeY 3 - :col 1 - :row 1 - :parameter_mappings [{:foo "bar"}] - :visualization_settings {} - :series [{:name "Test Card" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}]} - {:sizeX 4 - :sizeY 3 - :col 1 - :row 1 - :parameter_mappings [{:foo "bar"}] - :visualization_settings {} - :series [{:name "Test Card" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}]}] - (tt/with-temp* [Dashboard [{dashboard-id :id}] - Card [{card-id :id} {:name "Test Card"}]] - (let [dashboard-card (create-dashboard-card! {:creator_id (user->id :rasta) - :dashboard_id dashboard-id - :card_id card-id - :sizeX 4 - :sizeY 3 - :row 1 - :col 1 - :parameter_mappings [{:foo "bar"}] - :visualization_settings {} - :series [card-id]})] - ;; first result is return value from function, second is to validate db captured everything - [(remove-ids-and-timestamps dashboard-card) - (remove-ids-and-timestamps (retrieve-dashboard-card (:id dashboard-card)))]))) - -;; update-dashboard-card! -;; basic update. we are testing multiple things here -;; 1. ability to update all the normal attributes for size/position -;; 2. ability to update series and ensure proper ordering -;; 3. ensure the card_id cannot be changed -;; 4. ensure the dashboard_id cannot be changed -(expect - [{:sizeX 2 - :sizeY 2 - :col 0 - :row 0 - :parameter_mappings [{:foo "bar"}] - :visualization_settings {} - :series []} - {:sizeX 4 - :sizeY 3 - :col 1 - :row 1 - :parameter_mappings [{:foo "barbar"}] - :visualization_settings {} - :series [{:name "Test Card 2" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}} - {:name "Test Card 1" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}]} - {:sizeX 4 - :sizeY 3 - :col 1 - :row 1 - :parameter_mappings [{:foo "barbar"}] - :visualization_settings {} - :series [{:name "Test Card 2" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}} - {:name "Test Card 1" - :description nil - :display :table - :dataset_query {} - :visualization_settings {}}]}] - (tt/with-temp* [Dashboard [{dashboard-id :id}] - Card [{card-id :id}] - DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id, :card_id card-id, :parameter_mappings [{:foo "bar"}]}] - Card [{card-id-1 :id} {:name "Test Card 1"}] - Card [{card-id-2 :id} {:name "Test Card 2"}]] - ;; first result is the unmodified dashcard - ;; second is the return value from the update call - ;; third is to validate db captured everything - [(remove-ids-and-timestamps (retrieve-dashboard-card dashcard-id)) - (remove-ids-and-timestamps (update-dashboard-card! {:id dashcard-id - :actor_id (user->id :rasta) - :dashboard_id nil - :card_id nil - :sizeX 4 - :sizeY 3 - :row 1 - :col 1 - :parameter_mappings [{:foo "barbar"}] - :visualization_settings {} - :series [card-id-2 card-id-1]})) - (remove-ids-and-timestamps (retrieve-dashboard-card dashcard-id))])) +(deftest update-dashboard-card!-test + (testing (str "update-dashboard-card! basic update. We are testing multiple things here: 1. ability to update all " + "the normal attributes for size/position 2. ability to update series and ensure proper ordering 3. " + "ensure the card_id cannot be changed 4. ensure the dashboard_id cannot be changed") + (mt/with-temp* [Dashboard [{dashboard-id :id}] + Card [{card-id :id}] + DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id + :card_id card-id + :parameter_mappings [{:foo "bar"}]}] + Card [{card-id-1 :id} {:name "Test Card 1"}] + Card [{card-id-2 :id} {:name "Test Card 2"}]] + (testing "unmodified dashcard" + (is (= {:sizeX 2 + :sizeY 2 + :col 0 + :row 0 + :parameter_mappings [{:foo "bar"}] + :visualization_settings {} + :series []} + (remove-ids-and-timestamps (dashboard-card/retrieve-dashboard-card dashcard-id))))) + (testing "return value from the update call" + (is (= {:sizeX 4 + :sizeY 3 + :col 1 + :row 1 + :parameter_mappings [{:foo "barbar"}] + :visualization_settings {} + :series [{:name "Test Card 2" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}} + {:name "Test Card 1" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}]} + (remove-ids-and-timestamps + (dashboard-card/update-dashboard-card! + {:id dashcard-id + :actor_id (mt/user->id :rasta) + :dashboard_id nil + :card_id nil + :sizeX 4 + :sizeY 3 + :row 1 + :col 1 + :parameter_mappings [{:foo "barbar"}] + :visualization_settings {} + :series [card-id-2 card-id-1]}))))) + (testing "validate db captured everything" + (is (= {:sizeX 4 + :sizeY 3 + :col 1 + :row 1 + :parameter_mappings [{:foo "barbar"}] + :visualization_settings {} + :series [{:name "Test Card 2" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}} + {:name "Test Card 1" + :description nil + :display :table + :dataset_query {} + :visualization_settings {}}]} + (remove-ids-and-timestamps (dashboard-card/retrieve-dashboard-card dashcard-id)))))))) (deftest normalize-parameter-mappings-test (testing "DashboardCard parameter mappings should get normalized when coming out of the DB" diff --git a/test/metabase/models/dependency_test.clj b/test/metabase/models/dependency_test.clj index 7daa36fec83b1..bf860d53f5a99 100644 --- a/test/metabase/models/dependency_test.clj +++ b/test/metabase/models/dependency_test.clj @@ -1,12 +1,10 @@ (ns metabase.models.dependency-test (:require [clojure.test :refer :all] - [expectations :refer [expect]] [metabase.models.dependency :as dep :refer [Dependency]] + [metabase.test :as mt] [metabase.test.fixtures :as fixtures] - [metabase.test.util :as tu] [toucan.db :as db] - [toucan.models :as models] - [toucan.util.test :as tt])) + [toucan.models :as models])) (use-fixtures :once (fixtures/initialize :db)) @@ -19,16 +17,10 @@ {:a [1 2] :b [3 4 5]})}) - -;; IDependent/dependencies - -(expect - {:a [1 2] - :b [3 4 5]} - (dep/dependencies Mock 7 {})) - - -;; helper functions +(deftest dependencies-test + (is (= {:a [1 2] + :b [3 4 5]} + (dep/dependencies Mock 7 {})))) (defn format-dependencies [deps] (->> deps @@ -36,73 +28,66 @@ (map #(dissoc % :id :created_at)) set)) - -;; retrieve-dependencies - -(expect - #{{:model "Mock" - :model_id 4 - :dependent_on_model "test" - :dependent_on_id 1} - {:model "Mock" - :model_id 4 - :dependent_on_model "foobar" - :dependent_on_id 13}} - (tt/with-temp* [Dependency [_ {:model "Mock" - :model_id 4 +(deftest retrieve-dependencies-test + (testing "retrieve-dependencies" + (mt/with-temp* [Dependency [_ {:model "Mock" + :model_id 4 + :dependent_on_model "test" + :dependent_on_id 1 + :created_at :%now}] + Dependency [_ {:model "Mock" + :model_id 4 + :dependent_on_model "foobar" + :dependent_on_id 13 + :created_at :%now}]] + (is (= #{{:model "Mock" + :model_id 4 + :dependent_on_model "test" + :dependent_on_id 1} + {:model "Mock" + :model_id 4 + :dependent_on_model "foobar" + :dependent_on_id 13}} + (format-dependencies (dep/retrieve-dependencies Mock 4))))))) + +(deftest update-dependencies!-test + (testing "we skip over values which aren't integers" + (mt/with-model-cleanup [Dependency] + (dep/update-dependencies! Mock 2 {:test ["a" "b" "c"]}) + (is (= #{} + (set (db/select Dependency, :model "Mock", :model_id 2)))))) + + (testing "valid working dependencies list" + (mt/with-model-cleanup [Dependency] + (dep/update-dependencies! Mock 7 {:test [1 2 3]}) + (is (= #{{:model "Mock" + :model_id 7 + :dependent_on_model "test" + :dependent_on_id 1} + {:model "Mock" + :model_id 7 + :dependent_on_model "test" + :dependent_on_id 2} + {:model "Mock" + :model_id 7 + :dependent_on_model "test" + :dependent_on_id 3}} + (format-dependencies (db/select Dependency, :model "Mock", :model_id 7)))))) + + (testing "delete dependencies that are no longer in the list" + (mt/with-temp Dependency [_ {:model "Mock" + :model_id 1 :dependent_on_model "test" - :dependent_on_id 1 + :dependent_on_id 5 :created_at :%now}] - Dependency [_ {:model "Mock" - :model_id 4 - :dependent_on_model "foobar" - :dependent_on_id 13 - :created_at :%now}]] - (format-dependencies (dep/retrieve-dependencies Mock 4)))) - - -;; update-dependencies! - -;; we skip over values which aren't integers -(expect - #{} - (tu/with-model-cleanup [Dependency] - (dep/update-dependencies! Mock 2 {:test ["a" "b" "c"]}) - (set (db/select Dependency, :model "Mock", :model_id 2)))) - -;; valid working dependencies list -(expect - #{{:model "Mock" - :model_id 7 - :dependent_on_model "test" - :dependent_on_id 1} - {:model "Mock" - :model_id 7 - :dependent_on_model "test" - :dependent_on_id 2} - {:model "Mock" - :model_id 7 - :dependent_on_model "test" - :dependent_on_id 3}} - (tu/with-model-cleanup [Dependency] - (dep/update-dependencies! Mock 7 {:test [1 2 3]}) - (format-dependencies (db/select Dependency, :model "Mock", :model_id 7)))) - -;; delete dependencies that are no longer in the list -(expect - #{{:model "Mock" - :model_id 1 - :dependent_on_model "test" - :dependent_on_id 1} - {:model "Mock" - :model_id 1 - :dependent_on_model "test" - :dependent_on_id 2}} - (tt/with-temp Dependency [_ {:model "Mock" - :model_id 1 - :dependent_on_model "test" - :dependent_on_id 5 - :created_at :%now}] - (tu/with-model-cleanup [Dependency] - (dep/update-dependencies! Mock 1 {:test [1 2]}) - (format-dependencies (db/select Dependency, :model "Mock", :model_id 1))))) + (mt/with-model-cleanup [Dependency] + (dep/update-dependencies! Mock 1 {:test [1 2]}) + (is (= #{{:model "Mock" + :model_id 1 + :dependent_on_model "test" + :dependent_on_id 1} + {:model "Mock" + :model_id 1 + :dependent_on_model "test" + :dependent_on_id 2}} + (format-dependencies (db/select Dependency, :model "Mock", :model_id 1)))))))) diff --git a/test/metabase/models/revision/diff_test.clj b/test/metabase/models/revision/diff_test.clj index 949f95bbd3977..9a1c0dbd2d3d4 100644 --- a/test/metabase/models/revision/diff_test.clj +++ b/test/metabase/models/revision/diff_test.clj @@ -1,30 +1,36 @@ (ns metabase.models.revision.diff-test (:require [clojure.data :as data] - [expectations :refer :all] - [metabase.models.revision.diff :refer :all])) + [clojure.test :refer :all] + [metabase.models.revision.diff :as diff])) -;; Check that pattern matching allows specialization and that string only reflects the keys that have changed -(expect "renamed this card from \"Tips by State\" to \"Spots by State\"." - (let [[before after] (data/diff {:name "Tips by State", :private false} - {:name "Spots by State", :private false})] - (diff-string "card" before after))) +(deftest rename-test + (testing (str "Check that pattern matching allows specialization and that string only reflects the keys that have " + "changed") + (let [[before after] (data/diff {:name "Tips by State", :private false} + {:name "Spots by State", :private false})] + (is (= "renamed this card from \"Tips by State\" to \"Spots by State\"." + (diff/diff-string "card" before after)))))) -(expect "made this card private." +(deftest make-private-test (let [[before after] (data/diff {:name "Spots by State", :private false} {:name "Spots by State", :private true})] - (diff-string "card" before after))) + (is (= "made this card private." + (diff/diff-string "card" before after))))) -(expect "changed priority from \"Important\" to \"Regular\"." +(deftest change-priority-test (let [[before after] (data/diff {:priority "Important"} {:priority "Regular"})] - (diff-string "card" before after))) + (is (= "changed priority from \"Important\" to \"Regular\"." + (diff/diff-string "card" before after))))) -(expect "made this card private and renamed it from \"Tips by State\" to \"Spots by State\"." +(deftest multiple-changes-test (let [[before after] (data/diff {:name "Tips by State", :private false} {:name "Spots by State", :private true})] - (diff-string "card" before after))) + (is (= "made this card private and renamed it from \"Tips by State\" to \"Spots by State\"." + (diff/diff-string "card" before after)))) -(expect "changed priority from \"Important\" to \"Regular\", made this card private and renamed it from \"Tips by State\" to \"Spots by State\"." (let [[before after] (data/diff {:name "Tips by State", :private false, :priority "Important"} {:name "Spots by State", :private true, :priority "Regular"})] - (diff-string "card" before after))) + (is (= (str "changed priority from \"Important\" to \"Regular\", made this card private and renamed it from " + "\"Tips by State\" to \"Spots by State\".") + (diff/diff-string "card" before after))))) diff --git a/test/metabase/models/revision_test.clj b/test/metabase/models/revision_test.clj index d9cf317966bdc..b7b595dc55a51 100644 --- a/test/metabase/models/revision_test.clj +++ b/test/metabase/models/revision_test.clj @@ -1,11 +1,9 @@ (ns metabase.models.revision-test - (:require [expectations :refer :all] + (:require [clojure.test :refer :all] [metabase.models.card :refer [Card]] - [metabase.models.revision :as revision :refer :all] - [metabase.test.data.users :refer :all] - [metabase.util :as u] - [toucan.models :as models] - [toucan.util.test :as tt])) + [metabase.models.revision :as revision] + [metabase.test :as mt] + [toucan.models :as models])) (def ^:private reverted-to (atom nil)) @@ -13,7 +11,7 @@ (models/defmodel ^:private FakedCard :report_card) (extend-type (class FakedCard) - IRevisioned + revision/IRevisioned (serialize-instance [_ _ obj] (assoc obj :serialized true)) (revert-to-revision! [_ _ _ serialized-instance] @@ -24,208 +22,222 @@ (when o1 (str "BEFORE=" o1 ",AFTER=" o2)))) -(defn- push-fake-revision [card-id & {:keys [message] :as object}] - (push-revision! +(defn- push-fake-revision! [card-id & {:keys [message] :as object}] + (revision/push-revision! :entity FakedCard :id card-id - :user-id (user->id :rasta) + :user-id (mt/user->id :rasta) :object (dissoc object :message) :message message)) -;; make sure we call the appropriate post-select methods on `:object` when a revision comes out of the DB. This is -;; especially important for things like Cards where we need to make sure query is normalized -(expect - {:model "Card", :object {:dataset_query {:type :query}}} - (#'revision/do-post-select-for-object {:model "Card", :object {:dataset_query {:type "query"}}})) +(deftest post-select-test + (testing (str "make sure we call the appropriate post-select methods on `:object` when a revision comes out of the " + "DB. This is especially important for things like Cards where we need to make sure query is " + "normalized") + (is (= {:model "Card", :object {:dataset_query {:type :query}}} + (mt/derecordize + (#'revision/do-post-select-for-object {:model "Card", :object {:dataset_query {:type "query"}}}))))) ;;; # Default diff-* implementations -;; Check that pattern matching allows specialization and that string only reflects the keys that have changed -(expect "renamed this Card from \"Tips by State\" to \"Spots by State\"." - (default-diff-str Card - {:name "Tips by State", :private false} - {:name "Spots by State", :private false})) - -(expect "made this Card private." - (default-diff-str Card - {:name "Spots by State", :private false} - {:name "Spots by State", :private true})) - - -;; Check the fallback sentence fragment for key without specialized sentence fragment -(expect "changed priority from \"Important\" to \"Regular\"." - (default-diff-str Card - {:priority "Important"} - {:priority "Regular"})) - -;; Check that 2 changes are handled nicely -(expect "made this Card private and renamed it from \"Tips by State\" to \"Spots by State\"." - (default-diff-str Card - {:name "Tips by State", :private false} - {:name "Spots by State", :private true})) - -;; Check that several changes are handled nicely -(expect "changed priority from \"Important\" to \"Regular\", made this Card private and renamed it from \"Tips by State\" to \"Spots by State\"." - (default-diff-str Card - {:name "Tips by State", :private false, :priority "Important"} - {:name "Spots by State", :private true, :priority "Regular"})) - + (deftest default-diff-str-test + (testing (str "Check that pattern matching allows specialization and that string only reflects the keys that have " + "changed") + (is (= "renamed this Card from \"Tips by State\" to \"Spots by State\"." + (revision/default-diff-str Card + {:name "Tips by State", :private false} + {:name "Spots by State", :private false}))) + + (is (= "made this Card private." + (revision/default-diff-str Card + {:name "Spots by State", :private false} + {:name "Spots by State", :private true})))))) + +(deftest fallback-description-test + (testing "Check the fallback sentence fragment for key without specialized sentence fragment" + (is (= "changed priority from \"Important\" to \"Regular\"." + (revision/default-diff-str Card + {:priority "Important"} + {:priority "Regular"}))))) + +(deftest multiple-changes-test + (testing "Check that 2 changes are handled nicely" + (is (= "made this Card private and renamed it from \"Tips by State\" to \"Spots by State\"." + (revision/default-diff-str Card + {:name "Tips by State", :private false} + {:name "Spots by State", :private true})))) + + (testing "Check that several changes are handled nicely" + (is (= (str "changed priority from \"Important\" to \"Regular\", made this Card private and renamed it from " + "\"Tips by State\" to \"Spots by State\".") + (revision/default-diff-str Card + {:name "Tips by State", :private false, :priority "Important"} + {:name "Spots by State", :private true, :priority "Regular"}))))) ;;; # REVISIONS + PUSH-REVISION! -;; Test that a newly created Card doesn't have any revisions -(expect - [] - (tt/with-temp Card [{card-id :id}] - (revisions FakedCard card-id))) - -;; Test that we can add a revision -(expect - [(map->RevisionInstance - {:model "FakedCard" - :user_id (user->id :rasta) - :object {:name "Tips Created by Day", :serialized true} - :is_reversion false - :is_creation false - :message "yay!"})] - (tt/with-temp Card [{card-id :id}] - (push-fake-revision card-id, :name "Tips Created by Day", :message "yay!") - (for [revision (revisions FakedCard card-id)] - (dissoc revision :timestamp :id :model_id)))) - -;; Test that revisions are sorted in reverse chronological order -(expect [(map->RevisionInstance - {:model "FakedCard" - :user_id (user->id :rasta) - :object {:name "Spots Created by Day", :serialized true} - :is_reversion false - :is_creation false - :message nil}) - (map->RevisionInstance - {:model "FakedCard" - :user_id (user->id :rasta) - :object {:name "Tips Created by Day", :serialized true} - :is_reversion false - :is_creation false - :message nil})] - (tt/with-temp Card [{card-id :id}] - (push-fake-revision card-id, :name "Tips Created by Day") - (push-fake-revision card-id, :name "Spots Created by Day") - (->> (revisions FakedCard card-id) - (map (u/rpartial dissoc :timestamp :id :model_id))))) - -;; Check that old revisions get deleted -(expect max-revisions - (tt/with-temp Card [{card-id :id}] - ;; e.g. if max-revisions is 15 then insert 16 revisions - (dorun (repeatedly (inc max-revisions) #(push-fake-revision card-id, :name "Tips Created by Day"))) - (count (revisions FakedCard card-id)))) +(deftest new-object-no-revisions-test + (testing "Test that a newly created Card doesn't have any revisions" + (mt/with-temp Card [{card-id :id}] + (is (= [] + (revision/revisions FakedCard card-id)))))) + +(deftest add-revision-test + (testing "Test that we can add a revision" + (mt/with-temp Card [{card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day", :message "yay!") + (is (= [(revision/map->RevisionInstance + {:model "FakedCard" + :user_id (mt/user->id :rasta) + :object {:name "Tips Created by Day", :serialized true} + :is_reversion false + :is_creation false + :message "yay!"})] + (for [revision (revision/revisions FakedCard card-id)] + (dissoc revision :timestamp :id :model_id))))))) + +(deftest sorting-test + (testing "Test that revisions are sorted in reverse chronological order" + (mt/with-temp Card [{card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day") + (push-fake-revision! card-id, :name "Spots Created by Day") + (is (= [(revision/map->RevisionInstance + {:model "FakedCard" + :user_id (mt/user->id :rasta) + :object {:name "Spots Created by Day", :serialized true} + :is_reversion false + :is_creation false + :message nil}) + (revision/map->RevisionInstance + {:model "FakedCard" + :user_id (mt/user->id :rasta) + :object {:name "Tips Created by Day", :serialized true} + :is_reversion false + :is_creation false + :message nil})] + (->> (revision/revisions FakedCard card-id) + (map #(dissoc % :timestamp :id :model_id)))))))) + +(deftest delete-old-revisions-test + (testing "Check that old revisions get deleted" + (mt/with-temp Card [{card-id :id}] + ;; e.g. if max-revisions is 15 then insert 16 revisions + (dorun (repeatedly (inc revision/max-revisions) #(push-fake-revision! card-id, :name "Tips Created by Day"))) + (is (= revision/max-revisions + (count (revision/revisions FakedCard card-id))))))) ;;; # REVISIONS+DETAILS -;; Test that add-revision-details properly enriches our revision objects -(expect - {:is_creation false - :is_reversion false - :message nil - :user {:id (user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"} - :diff {:o1 {:name "Initial Name", :serialized true} - :o2 {:name "Modified Name", :serialized true}} - :description "BEFORE={:name \"Initial Name\", :serialized true},AFTER={:name \"Modified Name\", :serialized true}"} - (tt/with-temp Card [{card-id :id}] - (push-fake-revision card-id, :name "Initial Name") - (push-fake-revision card-id, :name "Modified Name") - (let [revisions (revisions FakedCard card-id)] - (assert (= 2 (count revisions))) - (-> (add-revision-details FakedCard (first revisions) (last revisions)) - (dissoc :timestamp :id :model_id))))) - -;; Check that revisions+details pulls in user info and adds description -(expect [(map->RevisionInstance - {:is_reversion false, - :is_creation false, - :message nil, - :user {:id (user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, - :diff {:o1 nil - :o2 {:name "Tips Created by Day", :serialized true}} - :description nil})] - (tt/with-temp Card [{card-id :id}] - (push-fake-revision card-id, :name "Tips Created by Day") - (->> (revisions+details FakedCard card-id) - (map (u/rpartial dissoc :timestamp :id :model_id))))) - -;; Check that revisions properly defer to describe-diff -(expect [(map->RevisionInstance - {:is_reversion false, - :is_creation false, - :message nil - :user {:id (user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, - :diff {:o1 {:name "Tips Created by Day", :serialized true} - :o2 {:name "Spots Created by Day", :serialized true}} - :description "BEFORE={:name \"Tips Created by Day\", :serialized true},AFTER={:name \"Spots Created by Day\", :serialized true}"}) - (map->RevisionInstance - {:is_reversion false, - :is_creation false, - :message nil - :user {:id (user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, - :diff {:o1 nil - :o2 {:name "Tips Created by Day", :serialized true}} - :description nil})] - (tt/with-temp Card [{card-id :id}] - (push-fake-revision card-id, :name "Tips Created by Day") - (push-fake-revision card-id, :name "Spots Created by Day") - (->> (revisions+details FakedCard card-id) - (map (u/rpartial dissoc :timestamp :id :model_id))))) +(deftest add-revision-details-test + (testing "Test that add-revision-details properly enriches our revision objects" + (mt/with-temp Card [{card-id :id}] + (push-fake-revision! card-id, :name "Initial Name") + (push-fake-revision! card-id, :name "Modified Name") + (is (= {:is_creation false + :is_reversion false + :message nil + :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"} + :diff {:o1 {:name "Initial Name", :serialized true} + :o2 {:name "Modified Name", :serialized true}} + :description "BEFORE={:name \"Initial Name\", :serialized true},AFTER={:name \"Modified Name\", :serialized true}"} + (let [revisions (revision/revisions FakedCard card-id)] + (assert (= 2 (count revisions))) + (-> (revision/add-revision-details FakedCard (first revisions) (last revisions)) + (dissoc :timestamp :id :model_id) + mt/derecordize))))))) + +(deftest revisions+details-test + (testing "Check that revisions+details pulls in user info and adds description" + (mt/with-temp Card [{card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day") + (is (= [(revision/map->RevisionInstance + {:is_reversion false, + :is_creation false, + :message nil, + :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, + :diff {:o1 nil + :o2 {:name "Tips Created by Day", :serialized true}} + :description nil})] + (->> (revision/revisions+details FakedCard card-id) + (map #(dissoc % :timestamp :id :model_id)))))))) + +(deftest defer-to-describe-diff-test + (testing "Check that revisions properly defer to describe-diff" + (mt/with-temp Card [{card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day") + (push-fake-revision! card-id, :name "Spots Created by Day") + (is (= [(revision/map->RevisionInstance + {:is_reversion false, + :is_creation false, + :message nil + :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, + :diff {:o1 {:name "Tips Created by Day", :serialized true} + :o2 {:name "Spots Created by Day", :serialized true}} + :description (str "BEFORE={:name \"Tips Created by Day\", :serialized true},AFTER=" + "{:name \"Spots Created by Day\", :serialized true}")}) + (revision/map->RevisionInstance + {:is_reversion false, + :is_creation false, + :message nil + :user {:id (mt/user->id :rasta), :common_name "Rasta Toucan", :first_name "Rasta", :last_name "Toucan"}, + :diff {:o1 nil + :o2 {:name "Tips Created by Day", :serialized true}} + :description nil})] + (->> (revision/revisions+details FakedCard card-id) + (map #(dissoc % :timestamp :id :model_id)))))))) ;;; # REVERT -;; Check that revert defers to revert-to-revision! -(expect {:name "Tips Created by Day"} - (tt/with-temp Card [{card-id :id}] - (push-fake-revision card-id, :name "Tips Created by Day") - (let [[{revision-id :id}] (revisions FakedCard card-id)] - (revert! :entity FakedCard, :id card-id, :user-id (user->id :rasta), :revision-id revision-id) - @reverted-to))) - -;; Check default impl of revert-to-revision! just does mapply upd -(expect ["Spots Created By Day" - "Tips Created by Day"] - (tt/with-temp Card [{card-id :id} {:name "Spots Created By Day"}] - (push-revision! :entity Card, :id card-id, :user-id (user->id :rasta), :object {:name "Tips Created by Day"}) - (push-revision! :entity Card, :id card-id, :user-id (user->id :rasta), :object {:name "Spots Created by Day"}) - [(:name (Card card-id)) - (let [[_ {old-revision-id :id}] (revisions Card card-id)] - (revert! :entity Card, :id card-id, :user-id (user->id :rasta), :revision-id old-revision-id) - (:name (Card card-id)))])) - -;; Check that reverting to a previous revision adds an appropriate revision -(expect [(map->RevisionInstance - {:model "FakedCard" - :user_id (user->id :rasta) - :object {:name "Tips Created by Day", :serialized true} - :is_reversion true - :is_creation false - :message nil}) - (map->RevisionInstance - {:model "FakedCard", - :user_id (user->id :rasta) - :object {:name "Spots Created by Day", :serialized true} - :is_reversion false - :is_creation false - :message nil}) - (map->RevisionInstance - {:model "FakedCard", - :user_id (user->id :rasta) - :object {:name "Tips Created by Day", :serialized true} - :is_reversion false - :is_creation false - :message nil})] - (tt/with-temp Card [{card-id :id}] - (push-fake-revision card-id, :name "Tips Created by Day") - (push-fake-revision card-id, :name "Spots Created by Day") - (let [[_ {old-revision-id :id}] (revisions FakedCard card-id)] - (revert! :entity FakedCard, :id card-id, :user-id (user->id :rasta), :revision-id old-revision-id) - (->> (revisions FakedCard card-id) - (map (u/rpartial dissoc :timestamp :id :model_id)))))) +(deftest revert-defer-to-revert-to-revision!-test + (testing "Check that revert defers to revert-to-revision!" + (mt/with-temp Card [{card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day") + (let [[{revision-id :id}] (revision/revisions FakedCard card-id)] + (revision/revert! :entity FakedCard, :id card-id, :user-id (mt/user->id :rasta), :revision-id revision-id) + (is (= {:name "Tips Created by Day"} + @reverted-to)))))) + +(deftest revert-to-revision!-default-impl-test + (testing "Check default impl of revert-to-revision! just does mapply upd" + (mt/with-temp Card [{card-id :id} {:name "Spots Created By Day"}] + (revision/push-revision! :entity Card, :id card-id, :user-id (mt/user->id :rasta), :object {:name "Tips Created by Day"}) + (revision/push-revision! :entity Card, :id card-id, :user-id (mt/user->id :rasta), :object {:name "Spots Created by Day"}) + (is (= "Spots Created By Day" + (:name (Card card-id)))) + (let [[_ {old-revision-id :id}] (revision/revisions Card card-id)] + (revision/revert! :entity Card, :id card-id, :user-id (mt/user->id :rasta), :revision-id old-revision-id) + (is (= "Tips Created by Day" + (:name (Card card-id)))))))) + +(deftest reverting-should-add-revision-test + (testing "Check that reverting to a previous revision adds an appropriate revision" + (mt/with-temp Card [{card-id :id}] + (push-fake-revision! card-id, :name "Tips Created by Day") + (push-fake-revision! card-id, :name "Spots Created by Day") + (let [[_ {old-revision-id :id}] (revision/revisions FakedCard card-id)] + (revision/revert! :entity FakedCard, :id card-id, :user-id (mt/user->id :rasta), :revision-id old-revision-id) + (is (= [(revision/map->RevisionInstance + {:model "FakedCard" + :user_id (mt/user->id :rasta) + :object {:name "Tips Created by Day", :serialized true} + :is_reversion true + :is_creation false + :message nil}) + (revision/map->RevisionInstance + {:model "FakedCard", + :user_id (mt/user->id :rasta) + :object {:name "Spots Created by Day", :serialized true} + :is_reversion false + :is_creation false + :message nil}) + (revision/map->RevisionInstance + {:model "FakedCard", + :user_id (mt/user->id :rasta) + :object {:name "Tips Created by Day", :serialized true} + :is_reversion false + :is_creation false + :message nil})] + (->> (revision/revisions FakedCard card-id) + (map #(dissoc % :timestamp :id :model_id))))))))) diff --git a/test/metabase/models/session_test.clj b/test/metabase/models/session_test.clj index 1db82df7b4744..4023ea6d86be7 100644 --- a/test/metabase/models/session_test.clj +++ b/test/metabase/models/session_test.clj @@ -1,8 +1,8 @@ (ns metabase.models.session-test - (:require [expectations :refer [expect]] + (:require [clojure.test :refer :all] [metabase.models.session :as session :refer [Session]] [metabase.server.middleware.misc :as mw.misc] - [metabase.test.data.users :as test-users] + [metabase.test :as mt] [toucan.db :as db] [toucan.models :as t.models])) @@ -12,25 +12,27 @@ ;; the way we'd expect :/ (defn- new-session [] (try - (db/insert! Session {:id (str test-uuid), :user_id (test-users/user->id :trashbird)}) + (db/insert! Session {:id (str test-uuid), :user_id (mt/user->id :trashbird)}) (-> (Session (str test-uuid)) t.models/post-insert (dissoc :created_at)) (finally (db/delete! Session :id (str test-uuid))))) -;; when creating a new Session, it should come back with an added `:type` key -(expect - {:id "092797dd-a82a-4748-b393-697d7bb9ab65" - :user_id (test-users/user->id :trashbird) - :anti_csrf_token nil - :type :normal} - (new-session)) +(deftest new-session-include-test-test + (testing "when creating a new Session, it should come back with an added `:type` key" + (is (= {:id "092797dd-a82a-4748-b393-697d7bb9ab65" + :user_id (mt/user->id :trashbird) + :anti_csrf_token nil + :type :normal} + (mt/derecordize + (new-session)))))) -;; if request is an embedding request, we should get ourselves an embedded Session -(expect - {:id "092797dd-a82a-4748-b393-697d7bb9ab65" - :user_id (test-users/user->id :trashbird) - :anti_csrf_token "315c1279c6f9f873bf1face7afeee420" - :type :full-app-embed} - (binding [mw.misc/*request* {:headers {"x-metabase-embedded" "true"}}] - (with-redefs [session/random-anti-csrf-token (constantly "315c1279c6f9f873bf1face7afeee420")] - (new-session)))) +(deftest embedding-test + (testing "if request is an embedding request, we should get ourselves an embedded Session" + (binding [mw.misc/*request* {:headers {"x-metabase-embedded" "true"}}] + (with-redefs [session/random-anti-csrf-token (constantly "315c1279c6f9f873bf1face7afeee420")] + (is (= {:id "092797dd-a82a-4748-b393-697d7bb9ab65" + :user_id (mt/user->id :trashbird) + :anti_csrf_token "315c1279c6f9f873bf1face7afeee420" + :type :full-app-embed} + (mt/derecordize + (new-session)))))))) diff --git a/test/metabase/models/task_history_test.clj b/test/metabase/models/task_history_test.clj index 5b99e451cb8f3..b75b7ce258439 100644 --- a/test/metabase/models/task_history_test.clj +++ b/test/metabase/models/task_history_test.clj @@ -1,11 +1,10 @@ (ns metabase.models.task-history-test - (:require [expectations :refer :all] + (:require [clojure.test :refer :all] [java-time :as t] - [metabase.models.task-history :refer :all] - [metabase.test.util :as tu] + [metabase.models.task-history :as task-history :refer [TaskHistory]] + [metabase.test :as mt] [metabase.util :as u] - [toucan.db :as db] - [toucan.util.test :as tt])) + [toucan.db :as db])) (defn add-second "Adds one second to `t`" @@ -26,46 +25,45 @@ :ended_at ended-at :duration (.between java.time.temporal.ChronoUnit/MILLIS started-at ended-at)})) -;; Basic cleanup test where older rows are deleted and newer rows kept -(let [task-4 (tu/random-name) - task-5 (tu/random-name)] - (expect - #{task-4 task-5} - (let [t1-start (t/zoned-date-time) +(deftest cleanup-test + (testing "Basic cleanup test where older rows are deleted and newer rows kept" + (let [task-4 (mt/random-name) + task-5 (mt/random-name) + t1-start (t/zoned-date-time) t2-start (add-second t1-start) t3-start (add-second t2-start) t4-start (add-second t3-start) t5-start (add-second t4-start)] - (tt/with-temp* [TaskHistory [t1 (make-10-millis-task t1-start)] + (mt/with-temp* [TaskHistory [t1 (make-10-millis-task t1-start)] TaskHistory [t2 (make-10-millis-task t2-start)] TaskHistory [t3 (make-10-millis-task t3-start)] TaskHistory [t4 (assoc (make-10-millis-task t4-start) - :task task-4)] + :task task-4)] TaskHistory [t5 (assoc (make-10-millis-task t5-start) - :task task-5)]] - ;; When the sync process runs, it creates several TaskHistory rows. We just want to work with the temp ones - ;; created, so delete any stale ones from previous tests - (db/delete! TaskHistory :id [:not-in (map u/get-id [t1 t2 t3 t4 t5])]) + :task task-5)]] + ;; When the sync process runs, it creates several TaskHistory rows. We just want to work with the + ;; temp ones created, so delete any stale ones from previous tests + (db/delete! TaskHistory :id [:not-in (map u/the-id [t1 t2 t3 t4 t5])]) ;; Delete all but 2 task history rows - (cleanup-task-history! 2) - (set (map :task (TaskHistory))))))) + (task-history/cleanup-task-history! 2) + (is (= #{task-4 task-5} + (set (map :task (TaskHistory))))))))) -;; Basic cleanup test where no work needs to be done and nothing is deleted -(let [task-1 (tu/random-name) - task-2 (tu/random-name)] - (expect - [#{task-1 task-2} - #{task-1 task-2}] - (let [t1-start (t/zoned-date-time) +(deftest no-op-test + (testing "Basic cleanup test where no work needs to be done and nothing is deleted" + (let [task-1 (mt/random-name) + task-2 (mt/random-name) + t1-start (t/zoned-date-time) t2-start (add-second t1-start)] - (tt/with-temp* [TaskHistory [t1 (assoc (make-10-millis-task t1-start) - :task task-1)] + (mt/with-temp* [TaskHistory [t1 (assoc (make-10-millis-task t1-start) + :task task-1)] TaskHistory [t2 (assoc (make-10-millis-task t2-start) - :task task-2)]] + :task task-2)]] ;; Cleanup any stale TalkHistory entries that are not the two being tested - (db/delete! TaskHistory :id [:not-in (map u/get-id [t1 t2])]) + (db/delete! TaskHistory :id [:not-in (map u/the-id [t1 t2])]) ;; We're keeping 100 rows, but there are only 2 present, so there should be no affect on running this - [(set (map :task (TaskHistory))) - (do - (cleanup-task-history! 100) - (set (map :task (TaskHistory))))])))) + (is (= #{task-1 task-2} + (set (map :task (TaskHistory))))) + (task-history/cleanup-task-history! 100) + (is (= #{task-1 task-2} + (set (map :task (TaskHistory))))))))) diff --git a/test/metabase/plugins/classloader_test.clj b/test/metabase/plugins/classloader_test.clj index 687162c350900..32dea89d48b32 100644 --- a/test/metabase/plugins/classloader_test.clj +++ b/test/metabase/plugins/classloader_test.clj @@ -1,47 +1,40 @@ (ns metabase.plugins.classloader-test - (:require [expectations :refer [expect]] + (:require [clojure.test :refer :all] [metabase.plugins.classloader :as classloader]) (:import clojure.lang.DynamicClassLoader)) -;; make sure we correctly detect when the current thread has the shared dynamic classloader as an ancestor -(expect - false - (do +(deftest has-shared-context-classloader-as-ancestor?-test + (testing "make sure we correctly detect when the current thread has the shared dynamic classloader as an ancestor" (.setContextClassLoader (Thread/currentThread) (ClassLoader/getSystemClassLoader)) - (#'classloader/has-shared-context-classloader-as-ancestor? (.getContextClassLoader (Thread/currentThread))))) + (is (= false + (#'classloader/has-shared-context-classloader-as-ancestor? (.getContextClassLoader (Thread/currentThread))))) -(expect - (do - (.setContextClassLoader (Thread/currentThread) @@#'classloader/shared-context-classloader) - (#'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))))) -(expect - (do - (.setContextClassLoader (Thread/currentThread) (DynamicClassLoader. @@#'classloader/shared-context-classloader)) - (#'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))))))) -;; if the current thread does NOT have a context classloader that is a descendent of the shared context classloader, -;; calling `the-classloader` should set it as a side-effect -(expect - @@#'classloader/shared-context-classloader - (do +(deftest set-context-classloader-test + (testing (str "if the current thread does NOT have a context classloader that is a descendent of the shared context " + "classloader, calling `the-classloader` should set it as a side-effect") (.setContextClassLoader (Thread/currentThread) (ClassLoader/getSystemClassLoader)) (classloader/the-classloader) - (.getContextClassLoader (Thread/currentThread)))) + (is (= @@#'classloader/shared-context-classloader + (.getContextClassLoader (Thread/currentThread))))) -;; if current thread context classloader === the shared context classloader it should be kept as-is -(expect - @@#'classloader/shared-context-classloader - (do + (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) - (.getContextClassLoader (Thread/currentThread)))) + (is (= @@#'classloader/shared-context-classloader + (.getContextClassLoader (Thread/currentThread))))) -;; 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)] - (expect - descendant-classloader - (do + (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)] (.setContextClassLoader (Thread/currentThread) descendant-classloader) (classloader/the-classloader) - (.getContextClassLoader (Thread/currentThread))))) + (is (= descendant-classloader + (.getContextClassLoader (Thread/currentThread))))))) diff --git a/test/metabase/query_processor/middleware/constraints_test.clj b/test/metabase/query_processor/middleware/constraints_test.clj index ca82a7ea88280..ab9c2e527b9bf 100644 --- a/test/metabase/query_processor/middleware/constraints_test.clj +++ b/test/metabase/query_processor/middleware/constraints_test.clj @@ -1,53 +1,52 @@ (ns metabase.query-processor.middleware.constraints-test - (:require [expectations :refer [expect]] + (:require [clojure.test :refer :all] [metabase.query-processor.middleware.constraints :as constraints] [metabase.test :as mt])) (defn- add-default-userland-constraints [query] (:pre (mt/test-qp-middleware constraints/add-default-userland-constraints query))) -;; don't do anything to queries without [:middleware :add-default-userland-constraints?] set -(expect - {} - (add-default-userland-constraints {})) +(deftest no-op-without-middleware-options-test + (testing "don't do anything to queries without [:middleware :add-default-userland-constraints?] set" + (is (= {} + (add-default-userland-constraints {}))))) -;; if it is *truthy* add the constraints -(expect - {:middleware {:add-default-userland-constraints? true}, - :constraints {:max-results @#'constraints/max-results - :max-results-bare-rows @#'constraints/max-results-bare-rows}} - (add-default-userland-constraints - {:middleware {:add-default-userland-constraints? true}})) +(deftest add-constraints-test + (testing "if it is *truthy* add the constraints" + (is (= {:middleware {:add-default-userland-constraints? true}, + :constraints {:max-results @#'constraints/max-results + :max-results-bare-rows @#'constraints/max-results-bare-rows}} + (add-default-userland-constraints + {:middleware {:add-default-userland-constraints? true}}))))) -;; don't do anything if it's not truthy -(expect - {:middleware {:add-default-userland-constraints? false}} - (add-default-userland-constraints - {:middleware {:add-default-userland-constraints? false}})) +(deftest no-op-if-option-is-false-test + (testing "don't do anything if it's not truthy" + (is (= {:middleware {:add-default-userland-constraints? false}} + (add-default-userland-constraints + {:middleware {:add-default-userland-constraints? false}}))))) -;; if it already has constraints, don't overwrite those! -(expect - {:middleware {:add-default-userland-constraints? true} - :constraints {:max-results @#'constraints/max-results - :max-results-bare-rows 1}} - (add-default-userland-constraints - {:constraints {:max-results-bare-rows 1} - :middleware {:add-default-userland-constraints? true}})) +(deftest dont-overwrite-existing-constraints-test + (testing "if it already has constraints, don't overwrite those!" + (is (= {:middleware {:add-default-userland-constraints? true} + :constraints {:max-results @#'constraints/max-results + :max-results-bare-rows 1}} + (add-default-userland-constraints + {:constraints {:max-results-bare-rows 1} + :middleware {:add-default-userland-constraints? true}}))))) -;; if you specify just `:max-results` it should make sure `:max-results-bare-rows` is <= `:max-results` -(expect - {:middleware {:add-default-userland-constraints? true} - :constraints {:max-results 5 - :max-results-bare-rows 5}} - (add-default-userland-constraints - {:constraints {:max-results 5} - :middleware {:add-default-userland-constraints? true}})) +(deftest max-results-bare-rows-should-be-less-than-max-results-test + (testing "if you specify just `:max-results` it should make sure `:max-results-bare-rows` is <= `:max-results`" + (is (= {:middleware {:add-default-userland-constraints? true} + :constraints {:max-results 5 + :max-results-bare-rows 5}} + (add-default-userland-constraints + {:constraints {:max-results 5} + :middleware {:add-default-userland-constraints? true}})))) -;; if you specify both it should still make sure `:max-results-bare-rows` is <= `:max-results` -(expect - {:middleware {:add-default-userland-constraints? true} - :constraints {:max-results 5 - :max-results-bare-rows 5}} - (add-default-userland-constraints - {:constraints {:max-results 5, :max-results-bare-rows 10} - :middleware {:add-default-userland-constraints? true}})) + (testing "if you specify both it should still make sure `:max-results-bare-rows` is <= `:max-results`" + (is (= {:middleware {:add-default-userland-constraints? true} + :constraints {:max-results 5 + :max-results-bare-rows 5}} + (add-default-userland-constraints + {:constraints {:max-results 5, :max-results-bare-rows 10} + :middleware {:add-default-userland-constraints? true}}))))) diff --git a/test/metabase/query_processor/store_test.clj b/test/metabase/query_processor/store_test.clj index 433c2117bf7d8..ee6b57620a7cb 100644 --- a/test/metabase/query_processor/store_test.clj +++ b/test/metabase/query_processor/store_test.clj @@ -1,48 +1,48 @@ (ns metabase.query-processor.store-test - (:require [expectations :refer [expect]] + (:require [clojure.test :refer :all] [metabase.query-processor.store :as qp.store])) -;; make sure `cached` only evaluates its body once during the duration of a QP run -(expect - {:value :ok, :eval-count 1} - (let [eval-count (atom 0) - cached-value (fn [] - (qp.store/cached :value - (swap! eval-count inc) - :ok))] - (qp.store/with-store - (cached-value) - (cached-value) - {:value (cached-value) - :eval-count @eval-count}))) +(deftest cached-test + (testing "make sure `cached` only evaluates its body once during the duration of a QP run" + (let [eval-count (atom 0) + cached-value (fn [] + (qp.store/cached :value + (swap! eval-count inc) + :ok))] + (qp.store/with-store + (cached-value) + (cached-value) + (is (= {:value :ok, :eval-count 1} + {:value (cached-value) + :eval-count @eval-count})))))) -;; multiple calls to `with-store` should keep the existing store if one is already established -(expect - {:value :ok, :eval-count 1} - (let [eval-count (atom 0) - cached-value (fn [] - (qp.store/cached :value - (swap! eval-count inc) - :ok))] - (qp.store/with-store - (cached-value) +(deftest reuse-existing-store-test + (testing "multiple calls to `with-store` should keep the existing store if one is already established" + (let [eval-count (atom 0) + cached-value (fn [] + (qp.store/cached :value + (swap! eval-count inc) + :ok))] (qp.store/with-store (cached-value) (qp.store/with-store - {:value (cached-value) - :eval-count @eval-count}))))) + (cached-value) + (is (= {:value :ok, :eval-count 1} + (qp.store/with-store + {:value (cached-value) + :eval-count @eval-count})))))))) -;; caching should be unique for each key -(expect - {:a :a, :b :b, :eval-count 2} - (let [eval-count (atom 0) - cached-value (fn [x] - (qp.store/cached x - (swap! eval-count inc) - x))] - (qp.store/with-store - (cached-value :a) - (cached-value :b) - {:a (cached-value :a) - :b (cached-value :b) - :eval-count @eval-count}))) +(deftest caching-unique-key-test + (testing "caching should be unique for each key" + (let [eval-count (atom 0) + cached-value (fn [x] + (qp.store/cached x + (swap! eval-count inc) + x))] + (qp.store/with-store + (cached-value :a) + (cached-value :b) + (is (= {:a :a, :b :b, :eval-count 2} + {:a (cached-value :a) + :b (cached-value :b) + :eval-count @eval-count})))))) diff --git a/test/metabase/sample_dataset_test.clj b/test/metabase/sample_dataset_test.clj index d93eb1e631367..73becd3ca6c11 100644 --- a/test/metabase/sample_dataset_test.clj +++ b/test/metabase/sample_dataset_test.clj @@ -1,15 +1,13 @@ (ns metabase.sample-dataset-test "Tests to make sure the Sample Dataset syncs the way we would expect." - (:require [expectations :refer :all] - [metabase.models.database :refer [Database]] - [metabase.models.field :refer [Field]] - [metabase.models.table :refer [Table]] + (:require [clojure.test :refer :all] + [metabase.models :refer [Database Field Table]] [metabase.sample-data :as sample-data] [metabase.sync :as sync] [metabase.util :as u] [toucan.db :as db] [toucan.hydrate :refer [hydrate]] - [toucan.util.test :as tt])) + [metabase.test :as mt])) ;;; ---------------------------------------------------- Tooling ----------------------------------------------------- @@ -24,7 +22,7 @@ "Execute `body` with a temporary Sample Dataset DB bound to `db-binding`." {:style/indent 1} [[db-binding] & body] - `(tt/with-temp Database [db# (sample-dataset-db)] + `(mt/with-temp Database [db# (sample-dataset-db)] (sync/sync-database! db#) (let [~db-binding db#] ~@body))) @@ -32,41 +30,41 @@ (defn- table "Get the Table in a `db` with `table-name`." [db table-name] - (db/select-one Table :name table-name, :db_id (u/get-id db))) + (db/select-one Table :name table-name, :db_id (u/the-id db))) (defn- field "Get the Field in a `db` with `table-name` and `field-name.`" [db table-name field-name] - (db/select-one Field :name field-name, :table_id (u/get-id (table db table-name)))) + (db/select-one Field :name field-name, :table_id (u/the-id (table db table-name)))) ;;; ----------------------------------------------------- Tests ------------------------------------------------------ -;; Make sure the Sample Dataset is getting synced correctly. For example PEOPLE.NAME should be has_field_values = search -;; instead of `list`. -(expect - {:description "The name of the user who owns an account" - :database_type "VARCHAR" - :semantic_type :type/Name - :name "NAME" - :has_field_values :search - :active true - :visibility_type :normal - :preview_display true - :display_name "Name" - :fingerprint {:global {:distinct-count 2499 - :nil% 0.0} - :type {:type/Text {:percent-json 0.0 - :percent-url 0.0 - :percent-email 0.0 - :percent-state 0.0 - :average-length 13.532}}} - :base_type :type/Text} - (with-temp-sample-dataset-db [db] - (-> (field db "PEOPLE" "NAME") - ;; it should be `nil` after sync but get set to `search` by the auto-inference. We only set `list` in sync and - ;; setting anything else is reserved for admins, however we fill in what we think should be the appropiate value - ;; with the hydration fn - (hydrate :has_field_values) - (select-keys [:name :description :database_type :semantic_type :has_field_values :active :visibility_type - :preview_display :display_name :fingerprint :base_type])))) +(deftest sync-sample-dataset-test + (testing (str "Make sure the Sample Dataset is getting synced correctly. For example PEOPLE.NAME should be " + "has_field_values = search instead of `list`.") + (with-temp-sample-dataset-db [db] + (is (= {:description "The name of the user who owns an account" + :database_type "VARCHAR" + :semantic_type :type/Name + :name "NAME" + :has_field_values :search + :active true + :visibility_type :normal + :preview_display true + :display_name "Name" + :fingerprint {:global {:distinct-count 2499 + :nil% 0.0} + :type {:type/Text {:percent-json 0.0 + :percent-url 0.0 + :percent-email 0.0 + :percent-state 0.0 + :average-length 13.532}}} + :base_type :type/Text} + (-> (field db "PEOPLE" "NAME") + ;; it should be `nil` after sync but get set to `search` by the auto-inference. We only set `list` in + ;; sync and setting anything else is reserved for admins, however we fill in what we think should be + ;; the appropiate value with the hydration fn + (hydrate :has_field_values) + (select-keys [:name :description :database_type :semantic_type :has_field_values :active :visibility_type + :preview_display :display_name :fingerprint :base_type]))))))) diff --git a/test/metabase/server/middleware/session_test.clj b/test/metabase/server/middleware/session_test.clj index 0cad59cef5291..552cf0ef30d4d 100644 --- a/test/metabase/server/middleware/session_test.clj +++ b/test/metabase/server/middleware/session_test.clj @@ -2,7 +2,6 @@ (:require [clojure.string :as str] [clojure.test :refer :all] [environ.core :as env] - [expectations :refer [expect]] [metabase.api.common :refer [*current-user* *current-user-id*]] [metabase.config :as config] [metabase.core.initialization-status :as init-status] @@ -11,11 +10,9 @@ [metabase.models :refer [Session User]] [metabase.server.middleware.session :as mw.session] [metabase.test :as mt] - [metabase.test.data.users :as test-users] [metabase.util.i18n :as i18n] [ring.mock.request :as mock] - [toucan.db :as db] - [toucan.util.test :as tt]) + [toucan.db :as db]) (:import clojure.lang.ExceptionInfo java.util.UUID)) @@ -121,16 +118,16 @@ :anti_csrf_token test-anti-csrf-token :type :full-app-embed}) -;; test that we can set a full-app-embedding session cookie -(expect - {:body {} - :status 200 - :cookies {embedded-session-cookie - {:value "092797dd-a82a-4748-b393-697d7bb9ab65" - :http-only true - :path "/"}} - :headers {anti-csrf-token-header test-anti-csrf-token}} - (mw.session/set-session-cookie {} {} test-full-app-embed-session)) +(deftest set-full-app-embedding-session-cookie-test + (testing "test that we can set a full-app-embedding session cookie" + (is (= {:body {} + :status 200 + :cookies {embedded-session-cookie + {:value "092797dd-a82a-4748-b393-697d7bb9ab65" + :http-only true + :path "/"}} + :headers {anti-csrf-token-header test-anti-csrf-token}} + (mw.session/set-session-cookie {} {} test-full-app-embed-session))))) ;;; ---------------------------------------- TEST wrap-session-id middleware ----------------------------------------- @@ -146,73 +143,69 @@ identity (fn [e] (throw e)))) - -;; no session-id in the request -(expect - nil - (-> (wrapped-handler (mock/request :get "/anyurl") ) - :metabase-session-id)) - - -;; extract session-id from header -(expect - "foobar" - (:metabase-session-id - (wrapped-handler - (mock/header (mock/request :get "/anyurl") session-header "foobar")))) - - -;; extract session-id from cookie -(expect - "cookie-session" - (:metabase-session-id - (wrapped-handler - (assoc (mock/request :get "/anyurl") - :cookies {session-cookie {:value "cookie-session"}})))) - - -;; if both header and cookie session-ids exist, then we expect the cookie to take precedence -(expect - "cookie-session" - (:metabase-session-id - (wrapped-handler - (assoc (mock/header (mock/request :get "/anyurl") session-header "foobar") - :cookies {session-cookie {:value "cookie-session"}})))) - -;; `wrap-session-id` should handle anti-csrf headers they way we'd expect -(expect - {:anti-csrf-token "84482ddf1bb178186ed9e1c0b1e05a2d" - :cookies {embedded-session-cookie {:value "092797dd-a82a-4748-b393-697d7bb9ab65"}} - :metabase-session-id "092797dd-a82a-4748-b393-697d7bb9ab65" - :uri "/anyurl"} - (let [request (-> (mock/request :get "/anyurl") - (assoc :cookies {embedded-session-cookie {:value (str test-uuid)}}) - (assoc-in [:headers anti-csrf-token-header] test-anti-csrf-token))] - (select-keys (wrapped-handler request) [:anti-csrf-token :cookies :metabase-session-id :uri]))) +(deftest no-session-id-in-request-test + (testing "no session-id in the request" + (is (= nil + (-> (wrapped-handler (mock/request :get "/anyurl") ) + :metabase-session-id))))) + +(deftest header-test + (testing "extract session-id from header" + (is (= "foobar" + (:metabase-session-id + (wrapped-handler + (mock/header (mock/request :get "/anyurl") session-header "foobar"))))))) + +(deftest cookie-test + (testing "extract session-id from cookie" + (is (= "cookie-session" + (:metabase-session-id + (wrapped-handler + (assoc (mock/request :get "/anyurl") + :cookies {session-cookie {:value "cookie-session"}}))))))) + +(deftest both-header-and-cookie-test + (testing "if both header and cookie session-ids exist, then we expect the cookie to take precedence" + (is (= "cookie-session" + (:metabase-session-id + (wrapped-handler + (assoc (mock/header (mock/request :get "/anyurl") session-header "foobar") + :cookies {session-cookie {:value "cookie-session"}}))))))) + +(deftest anti-csrf-headers-test + (testing "`wrap-session-id` should handle anti-csrf headers they way we'd expect" + (let [request (-> (mock/request :get "/anyurl") + (assoc :cookies {embedded-session-cookie {:value (str test-uuid)}}) + (assoc-in [:headers anti-csrf-token-header] test-anti-csrf-token))] + (is (= {:anti-csrf-token "84482ddf1bb178186ed9e1c0b1e05a2d" + :cookies {embedded-session-cookie {:value "092797dd-a82a-4748-b393-697d7bb9ab65"}} + :metabase-session-id "092797dd-a82a-4748-b393-697d7bb9ab65" + :uri "/anyurl"} + (select-keys (wrapped-handler request) [:anti-csrf-token :cookies :metabase-session-id :uri])))))) (deftest current-user-info-for-session-test (testing "make sure the `current-user-info-for-session` logic is working correctly" ;; for some reason Toucan seems to be busted with models with non-integer IDs and `with-temp` doesn't seem to work ;; the way we'd expect :/ (try - (tt/with-temp Session [session {:id (str test-uuid), :user_id (test-users/user->id :lucky)}] - (is (= {:metabase-user-id (test-users/user->id :lucky), :is-superuser? false, :user-locale nil} + (mt/with-temp Session [session {:id (str test-uuid), :user_id (mt/user->id :lucky)}] + (is (= {:metabase-user-id (mt/user->id :lucky), :is-superuser? false, :user-locale nil} (#'mw.session/current-user-info-for-session (str test-uuid) nil)))) (finally (db/delete! Session :id (str test-uuid))))) (testing "superusers should come back as `:is-superuser?`" (try - (tt/with-temp Session [session {:id (str test-uuid), :user_id (test-users/user->id :crowberto)}] - (is (= {:metabase-user-id (test-users/user->id :crowberto), :is-superuser? true, :user-locale nil} + (mt/with-temp Session [session {:id (str test-uuid), :user_id (mt/user->id :crowberto)}] + (is (= {:metabase-user-id (mt/user->id :crowberto), :is-superuser? true, :user-locale nil} (#'mw.session/current-user-info-for-session (str test-uuid) nil)))) (finally (db/delete! Session :id (str test-uuid))))) (testing "full-app-embed sessions shouldn't come back if we don't explicitly specifiy the anti-csrf token" (try - (tt/with-temp Session [session {:id (str test-uuid) - :user_id (test-users/user->id :lucky) + (mt/with-temp Session [session {:id (str test-uuid) + :user_id (mt/user->id :lucky) :anti_csrf_token test-anti-csrf-token}] (is (= nil (#'mw.session/current-user-info-for-session (str test-uuid) nil)))) @@ -221,18 +214,18 @@ (testing "...but if we do specifiy the token, they should come back" (try - (tt/with-temp Session [session {:id (str test-uuid) - :user_id (test-users/user->id :lucky) + (mt/with-temp Session [session {:id (str test-uuid) + :user_id (mt/user->id :lucky) :anti_csrf_token test-anti-csrf-token}] - (is (= {:metabase-user-id (test-users/user->id :lucky), :is-superuser? false, :user-locale nil} + (is (= {:metabase-user-id (mt/user->id :lucky), :is-superuser? false, :user-locale nil} (#'mw.session/current-user-info-for-session (str test-uuid) test-anti-csrf-token)))) (finally (db/delete! Session :id (str test-uuid)))) (testing "(unless the token is wrong)" (try - (tt/with-temp Session [session {:id (str test-uuid) - :user_id (test-users/user->id :lucky) + (mt/with-temp Session [session {:id (str test-uuid) + :user_id (mt/user->id :lucky) :anti_csrf_token test-anti-csrf-token}] (is (= nil (#'mw.session/current-user-info-for-session (str test-uuid) (str/join (reverse test-anti-csrf-token)))))) @@ -241,8 +234,8 @@ (testing "if we specify an anti-csrf token we shouldn't get back a session without that token" (try - (tt/with-temp Session [session {:id (str test-uuid) - :user_id (test-users/user->id :lucky)}] + (mt/with-temp Session [session {:id (str test-uuid) + :user_id (mt/user->id :lucky)}] (is (= nil (#'mw.session/current-user-info-for-session (str test-uuid) test-anti-csrf-token)))) (finally @@ -250,8 +243,8 @@ (testing "shouldn't fetch expired sessions" (try - (tt/with-temp Session [session {:id (str test-uuid) - :user_id (test-users/user->id :lucky)}] + (mt/with-temp Session [session {:id (str test-uuid) + :user_id (mt/user->id :lucky)}] ;; use low-level `execute!` because updating is normally disallowed for Sessions (db/execute! {:update Session, :set {:created_at (java.sql.Date. 0)}, :where [:= :id (str test-uuid)]}) (is (= nil @@ -261,7 +254,7 @@ (testing "shouldn't fetch sessions for inactive users" (try - (tt/with-temp Session [session {:id (str test-uuid), :user_id (test-users/user->id :trashbird)}] + (mt/with-temp Session [session {:id (str test-uuid), :user_id (mt/user->id :trashbird)}] (is (= nil (#'mw.session/current-user-info-for-session (str test-uuid) nil)))) (finally @@ -284,21 +277,19 @@ (-> (mock/request :get "/anyurl") (assoc :metabase-user-id user-id))) - -;; with valid user-id -(expect - {:user-id (test-users/user->id :rasta) - :user {:id (test-users/user->id :rasta) - :email (:email (test-users/fetch-user :rasta))}} - (user-bound-handler - (request-with-user-id (test-users/user->id :rasta)))) - -;; with invalid user-id (not sure how this could ever happen, but lets test it anyways) -(expect - {:user-id 0 - :user {}} - (user-bound-handler - (request-with-user-id 0))) +(deftest add-user-id-key-test + (testing "with valid user-id" + (is (= {:user-id (mt/user->id :rasta) + :user {:id (mt/user->id :rasta) + :email (:email (mt/fetch-user :rasta))}} + (user-bound-handler + (request-with-user-id (mt/user->id :rasta)))))) + + (testing "with invalid user-id (not sure how this could ever happen, but lets test it anyways)" + (is (= {:user-id 0 + :user {}} + (user-bound-handler + (request-with-user-id 0)))))) ;;; ----------------------------------------------------- Locale ----------------------------------------------------- diff --git a/test/metabase/sync/analyze/classifiers/category_test.clj b/test/metabase/sync/analyze/classifiers/category_test.clj index 972fb47b4c573..47fb38eabf8f2 100644 --- a/test/metabase/sync/analyze/classifiers/category_test.clj +++ b/test/metabase/sync/analyze/classifiers/category_test.clj @@ -1,6 +1,6 @@ (ns metabase.sync.analyze.classifiers.category-test "Tests for the category classifier." - (:require [expectations :refer :all] + (:require [clojure.test :refer :all] [metabase.sync.analyze.classifiers.category :as category-classifier])) (defn- field-with-distinct-count [distinct-count] @@ -22,13 +22,12 @@ :average-length 13.516}}} :base_type :type/Text}) -;; make sure the logic for deciding whether a Field should be a list works as expected -(expect - nil - (let [field (field-with-distinct-count 2500)] - (#'category-classifier/field-should-be-auto-list? (:fingerprint field) field))) +(deftest should-be-auto-list?-test + (testing "make sure the logic for deciding whether a Field should be a list works as expected" + (let [field (field-with-distinct-count 2500)] + (is (= nil + (#'category-classifier/field-should-be-auto-list? (:fingerprint field) field)))) -(expect - true - (let [field (field-with-distinct-count 99)] - (#'category-classifier/field-should-be-auto-list? (:fingerprint field) field))) + (let [field (field-with-distinct-count 99)] + (is (= true + (#'category-classifier/field-should-be-auto-list? (:fingerprint field) field)))))) diff --git a/test/metabase/sync/analyze/classifiers/no_preview_display_test.clj b/test/metabase/sync/analyze/classifiers/no_preview_display_test.clj index 8166878072c5e..d41fe2316d8b3 100644 --- a/test/metabase/sync/analyze/classifiers/no_preview_display_test.clj +++ b/test/metabase/sync/analyze/classifiers/no_preview_display_test.clj @@ -1,8 +1,8 @@ (ns metabase.sync.analyze.classifiers.no-preview-display-test "Tests for the category classifier." - (:require [expectations :refer :all] + (:require [clojure.test :refer :all] [metabase.models.field :as field] - [metabase.sync.analyze.classifiers.no-preview-display :refer :all])) + [metabase.sync.analyze.classifiers.no-preview-display :as no-preview-display])) (def ^:private long-text-field (field/map->FieldInstance @@ -24,21 +24,27 @@ :average-length 130.516}}} :base_type :type/Text})) -;; Leave short text fields intact -(expect - nil - (:preview_display (infer-no-preview-display long-text-field - (-> long-text-field - :fingerprint - (assoc-in [:type :type/Text :average-length] 2))))) +(deftest short-fields-test + (testing "Leave short text fields intact" + (is (= nil + (:preview_display + (no-preview-display/infer-no-preview-display + long-text-field + (-> long-text-field + :fingerprint + (assoc-in [:type :type/Text :average-length] 2)))))))) -;; Don't preview generic long text fields -(expect - false - (:preview_display (infer-no-preview-display long-text-field (:fingerprint long-text-field)))) +(deftest generic-long-text-fields-test + (testing "Don't preview generic long text fields" + (is (= false + (:preview_display + (no-preview-display/infer-no-preview-display + long-text-field (:fingerprint long-text-field))))))) -;; If the field has a semantic type, show it regardless of it's length -(expect - nil - (:preview_display (infer-no-preview-display (assoc long-text-field :semantic_type :type/Name) - (:fingerprint long-text-field)))) +(deftest semantic-type-test + (testing "If the field has a semantic type, show it regardless of it's length" + (is (= nil + (:preview_display + (no-preview-display/infer-no-preview-display + (assoc long-text-field :semantic_type :type/Name) + (:fingerprint long-text-field))))))) diff --git a/test/metabase/sync/sync_dynamic_test.clj b/test/metabase/sync/sync_dynamic_test.clj index 2aab59dcd541f..086bc92f52640 100644 --- a/test/metabase/sync/sync_dynamic_test.clj +++ b/test/metabase/sync/sync_dynamic_test.clj @@ -1,16 +1,14 @@ (ns metabase.sync.sync-dynamic-test "Tests for databases with a so-called 'dynamic' schema, i.e. one that is not hard-coded somewhere. A Mongo database is an example of such a DB. " - (:require [expectations :refer [expect]] - [metabase.models.database :refer [Database]] - [metabase.models.table :refer [Table]] + (:require [clojure.test :refer :all] + [metabase.models :refer [Database Table]] [metabase.sync :as sync] + [metabase.test :as mt] [metabase.test.mock.toucanery :as toucanery] - [metabase.test.util :as tu] [metabase.util :as u] [toucan.db :as db] - [toucan.hydrate :refer [hydrate]] - [toucan.util.test :as tt])) + [toucan.hydrate :refer [hydrate]])) (defn- remove-nonsense "Remove fields that aren't really relevant in the output for `tables` and their `fields`. Done for the sake of making @@ -26,12 +24,12 @@ [:table_id :name :fk_target_field_id :parent_id :base_type :database_type])))))))) (defn- get-tables [database-or-id] - (->> (hydrate (db/select Table, :db_id (u/get-id database-or-id), {:order-by [:id]}) :fields) - (mapv tu/boolean-ids-and-timestamps))) + (->> (hydrate (db/select Table, :db_id (u/the-id database-or-id), {:order-by [:id]}) :fields) + (mapv mt/boolean-ids-and-timestamps))) -;; basic test to make sure syncing nested fields works. This is sort of a higher-level test. -(expect - (remove-nonsense toucanery/toucanery-tables-and-fields) - (tt/with-temp* [Database [db {:engine ::toucanery/toucanery}]] - (sync/sync-database! db) - (remove-nonsense (get-tables db)))) +(deftest sync-nested-fields-test + (testing "basic test to make sure syncing nested fields works. This is sort of a higher-level test." + (mt/with-temp Database [db {:engine ::toucanery/toucanery}] + (sync/sync-database! db) + (is (= (remove-nonsense toucanery/toucanery-tables-and-fields) + (remove-nonsense (get-tables db))))))) diff --git a/test/metabase/sync/sync_metadata/fields_test.clj b/test/metabase/sync/sync_metadata/fields_test.clj index 08e59809f8cd0..235630a08c94b 100644 --- a/test/metabase/sync/sync_metadata/fields_test.clj +++ b/test/metabase/sync/sync_metadata/fields_test.clj @@ -2,13 +2,12 @@ "Tests for the logic that syncs Field models with the Metadata fetched from a DB. (There are more tests for this behavior in the namespace `metabase.sync-database.sync-dynamic-test`, which is sort of a misnomer.)" (:require [clojure.java.jdbc :as jdbc] - [expectations :refer [expect]] - [metabase.models.field :refer [Field]] - [metabase.models.table :refer [Table]] + [clojure.test :refer :all] + [metabase.models :refer [Field Table]] [metabase.query-processor :as qp] [metabase.sync :as sync] [metabase.sync.util-test :as sync.util-test] - [metabase.test.data :as data] + [metabase.test :as mt] [metabase.test.data.one-off-dbs :as one-off-dbs] [metabase.util :as u] [toucan.db :as db] @@ -43,131 +42,127 @@ "('Chicken', 'Colin Fowl');")]] (jdbc/execute! one-off-dbs/*conn* [statement])) ;; now sync - (sync/sync-database! (data/db)) + (sync/sync-database! (mt/db)) ;; ok, let's see what (f) gives us - (let [f-before (f (data/db))] + (let [f-before (f (mt/db))] ;; ok cool! now delete one of those columns... (jdbc/execute! one-off-dbs/*conn* ["ALTER TABLE \"birds\" DROP COLUMN \"example_name\";"]) ;; ...and re-sync... - (sync/sync-database! (data/db)) + (sync/sync-database! (mt/db)) ;; ...now let's see how (f) may have changed! Compare to original. {:before-drop f-before - :after-drop (f (data/db))}))) + :after-drop (f (mt/db))}))) +(deftest mark-inactive-test + (testing "make sure sync correctly marks a Field as active = false when it gets dropped from the DB" + (is (= {:before-drop #{{:name "species", :active true} + {:name "example_name", :active true}} + :after-drop #{{:name "species", :active true} + {:name "example_name", :active false}}} + (with-test-db-before-and-after-dropping-a-column + (fn [database] + (set + (map (partial into {}) + (db/select [Field :name :active] + :table_id [:in (db/select-ids Table :db_id (u/the-id database))]))))))))) -;; make sure sync correctly marks a Field as active = false when it gets dropped from the DB -(expect - {:before-drop #{{:name "species", :active true} - {:name "example_name", :active true}} - :after-drop #{{:name "species", :active true} - {:name "example_name", :active false}}} - (with-test-db-before-and-after-dropping-a-column - (fn [database] - (set - (map (partial into {}) - (db/select [Field :name :active] - :table_id [:in (db/select-ids Table :db_id (u/get-id database))])))))) +(deftest dont-show-deleted-fields-test + (testing "make sure deleted fields doesn't show up in `:fields` of a table" + (is (= {:before-drop #{"species" "example_name"} + :after-drop #{"species"}} + (with-test-db-before-and-after-dropping-a-column + (fn [database] + (let [table (hydrate (db/select-one Table :db_id (u/the-id database)) :fields)] + (set (map :name (:fields table)))))))))) -;; make sure deleted fields doesn't show up in `:fields` of a table -(expect - {:before-drop #{"species" "example_name"} - :after-drop #{"species"}} - (with-test-db-before-and-after-dropping-a-column - (fn [database] - (let [table (hydrate (db/select-one Table :db_id (u/get-id database)) :fields)] - (set - (map :name (:fields table))))))) - -;; make sure that inactive columns don't end up getting spliced into queries! This test arguably belongs in the query -;; processor tests since it's ultimately checking to make sure columns marked as `:active` = `false` aren't getting -;; put in queries with implicit `:fields` clauses, but since this could be seen as covering both QP and sync (my and -;; others' assumption when first coming across bug #6146 was that this was a sync issue), this test can stay here for -;; now along with the other test we have testing sync after dropping a column. -(expect - {:before-drop (str "SELECT \"PUBLIC\".\"birds\".\"species\" AS \"species\", " - "\"PUBLIC\".\"birds\".\"example_name\" AS \"example_name\" " - "FROM \"PUBLIC\".\"birds\" " - "LIMIT 1048576") - :after-drop (str "SELECT \"PUBLIC\".\"birds\".\"species\" AS \"species\" " - "FROM \"PUBLIC\".\"birds\" " - "LIMIT 1048576")} - (with-test-db-before-and-after-dropping-a-column - (fn [database] - (-> (qp/process-query {:database (u/get-id database) - :type :query - :query {:source-table (db/select-one-id Table - :db_id (u/get-id database), :name "birds")}}) - :data - :native_form - :query)))) +(deftest dont-splice-inactive-columns-into-queries-test + (testing (str "make sure that inactive columns don't end up getting spliced into queries! This test arguably " + "belongs in the query processor tests since it's ultimately checking to make sure columns marked as " + "`:active` = `false` aren't getting put in queries with implicit `:fields` clauses, but since this " + "could be seen as covering both QP and sync " + "(my and others' assumption when first coming across bug #6146 was that this was a sync issue), this " + "test can stay here for now along with the other test we have testing sync after dropping a column.") + (is (= {:before-drop (str "SELECT \"PUBLIC\".\"birds\".\"species\" AS \"species\", " + "\"PUBLIC\".\"birds\".\"example_name\" AS \"example_name\" " + "FROM \"PUBLIC\".\"birds\" " + "LIMIT 1048576") + :after-drop (str "SELECT \"PUBLIC\".\"birds\".\"species\" AS \"species\" " + "FROM \"PUBLIC\".\"birds\" " + "LIMIT 1048576")} + (with-test-db-before-and-after-dropping-a-column + (fn [database] + (-> (qp/process-query {:database (u/the-id database) + :type :query + :query {:source-table (db/select-one-id Table + :db_id (u/the-id database), :name "birds")}}) + :data + :native_form + :query))))))) ;;; +----------------------------------------------------------------------------------------------------------------+ ;;; | PK & FK Syncing | ;;; +----------------------------------------------------------------------------------------------------------------+ -;; Test PK Syncing -(expect - [:type/PK - nil - :type/PK - :type/Latitude - :type/PK] - (data/with-temp-copy-of-db - (let [get-semantic-type (fn [] (db/select-one-field :semantic_type Field, :id (data/id :venues :id)))] - [ ;; Semantic type should be :id to begin with - (get-semantic-type) - ;; Clear out the semantic type - (do (db/update! Field (data/id :venues :id), :semantic_type nil) - (get-semantic-type)) - ;; Calling sync-table! should set the semantic type again - (do (sync/sync-table! (Table (data/id :venues))) - (get-semantic-type)) - ;; sync-table! should *not* change the semantic type of fields that are marked with a different type - (do (db/update! Field (data/id :venues :id), :semantic_type :type/Latitude) - (get-semantic-type)) - ;; Make sure that sync-table runs set-table-pks-if-needed! - (do (db/update! Field (data/id :venues :id), :semantic_type nil) - (sync/sync-table! (Table (data/id :venues))) - (get-semantic-type))]))) - - -;; Check that Foreign Key relationships were created on sync as we expect -(expect - (data/id :venues :id) - (db/select-one-field :fk_target_field_id Field, :id (data/id :checkins :venue_id))) - -(expect - (data/id :users :id) - (db/select-one-field :fk_target_field_id Field, :id (data/id :checkins :user_id))) +(deftest pk-sync-test + (testing "Test PK Syncing" + (mt/with-temp-copy-of-db + (letfn [(get-semantic-type [] (db/select-one-field :semantic_type Field, :id (mt/id :venues :id)))] + (testing "Semantic type should be :id to begin with" + (is (= :type/PK + (get-semantic-type)))) + (testing "Clear out the semantic type" + (db/update! Field (mt/id :venues :id), :semantic_type nil) + (is (= nil + (get-semantic-type)))) + (testing "Calling sync-table! should set the semantic type again" + (sync/sync-table! (Table (mt/id :venues))) + (is (= :type/PK + (get-semantic-type)))) + (testing "sync-table! should *not* change the semantic type of fields that are marked with a different type" + (db/update! Field (mt/id :venues :id), :semantic_type :type/Latitude) + (is (= :type/Latitude + (get-semantic-type)))) + (testing "Make sure that sync-table runs set-table-pks-if-needed!" + (db/update! Field (mt/id :venues :id), :semantic_type nil) + (sync/sync-table! (Table (mt/id :venues))) + (is (= :type/PK + (get-semantic-type)))))))) -(expect - (data/id :categories :id) - (db/select-one-field :fk_target_field_id Field, :id (data/id :venues :category_id))) -;; Check that sync-table! causes FKs to be set like we'd expect -(expect - {:before - {:step-info {:total-fks 3, :updated-fks 0, :total-failed 0} - :task-details {:total-fks 3, :updated-fks 0, :total-failed 0} - :semantic-type :type/FK - :fk-target-exists? true} +(deftest fk-relationships-test + (testing "Check that Foreign Key relationships were created on sync as we expect" + (testing "checkins.venue_id" + (is (= (mt/id :venues :id) + (db/select-one-field :fk_target_field_id Field, :id (mt/id :checkins :venue_id))))) + (testing "checkins.user_id" + (is (= (mt/id :users :id) + (db/select-one-field :fk_target_field_id Field, :id (mt/id :checkins :user_id))))) + (testing "venues.category_id" + (is (= (mt/id :categories :id) + (db/select-one-field :fk_target_field_id Field, :id (mt/id :venues :category_id))))))) - :after - {:step-info {:total-fks 3, :updated-fks 1, :total-failed 0} - :task-details {:total-fks 3, :updated-fks 1, :total-failed 0} - :semantic-type :type/FK - :fk-target-exists? true}} - (data/with-temp-copy-of-db - (let [state (fn [] - (let [{:keys [step-info] - {:keys [task_details]} :task-history} (sync.util-test/sync-database! "sync-fks" (data/db)) - {:keys [semantic_type fk_target_field_id]} (db/select-one [Field :semantic_type :fk_target_field_id] - :id (data/id :checkins :user_id))] - {:step-info (sync.util-test/only-step-keys step-info) - :task-details task_details - :semantic-type semantic_type - :fk-target-exists? (db/exists? Field :id fk_target_field_id)}))] - (array-map - :before (state) - :after (do (db/update! Field (data/id :checkins :user_id), :semantic_type nil, :fk_target_field_id nil) - (state)))))) +(deftest sync-table-fks-test + (testing "Check that sync-table! causes FKs to be set like we'd expect" + (mt/with-temp-copy-of-db + (letfn [(state [] + (let [{:keys [step-info] + {:keys [task_details]} :task-history} (sync.util-test/sync-database! "sync-fks" (mt/db)) + {:keys [semantic_type fk_target_field_id]} (db/select-one [Field :semantic_type :fk_target_field_id] + :id (mt/id :checkins :user_id))] + {:step-info (sync.util-test/only-step-keys step-info) + :task-details task_details + :semantic-type semantic_type + :fk-target-exists? (db/exists? Field :id fk_target_field_id)}))] + (testing "before" + (is (= {:step-info {:total-fks 3, :updated-fks 0, :total-failed 0} + :task-details {:total-fks 3, :updated-fks 0, :total-failed 0} + :semantic-type :type/FK + :fk-target-exists? true} + (state)))) + (db/update! Field (mt/id :checkins :user_id), :semantic_type nil, :fk_target_field_id nil) + (testing "after" + (is (= {:step-info {:total-fks 3, :updated-fks 1, :total-failed 0} + :task-details {:total-fks 3, :updated-fks 1, :total-failed 0} + :semantic-type :type/FK + :fk-target-exists? true} + (state)))))))) diff --git a/test/metabase/sync/sync_metadata/sync_database_type_test.clj b/test/metabase/sync/sync_metadata/sync_database_type_test.clj index eff6b20d7b6a0..fea1a154095c2 100644 --- a/test/metabase/sync/sync_metadata/sync_database_type_test.clj +++ b/test/metabase/sync/sync_metadata/sync_database_type_test.clj @@ -1,64 +1,60 @@ (ns metabase.sync.sync-metadata.sync-database-type-test "Tests to make sure the newly added Field.database_type field gets populated, even for existing Fields." - (:require [expectations :refer :all] - [metabase.models.database :refer [Database]] - [metabase.models.field :refer [Field]] - [metabase.models.table :refer [Table]] + (:require [clojure.test :refer :all] + [metabase.models :refer [Database Field Table]] [metabase.sync :as sync] - [metabase.sync.util-test :as sut] - [metabase.test.data :as data] + [metabase.sync.util-test :as sync.util-test] + [metabase.test :as mt] [metabase.util :as u] - [toucan.db :as db] - [toucan.util.test :as tt])) + [toucan.db :as db])) -;; make sure that if a driver reports back a different database-type the Field gets updated accordingly -(expect - (concat - (repeat 2 {:total-fields 16 :updated-fields 6}) - [#{{:name "NAME", :database_type "VARCHAR"} - {:name "LATITUDE", :database_type "DOUBLE"} - {:name "LONGITUDE", :database_type "DOUBLE"} - {:name "ID", :database_type "BIGINT"} - {:name "PRICE", :database_type "INTEGER"} - {:name "CATEGORY_ID", :database_type "INTEGER"}}]) - ;; create a copy of the sample dataset :D - (tt/with-temp Database [db (select-keys (data/db) [:details :engine])] - (sync/sync-database! db) - (let [venues-table (Table :db_id (u/get-id db), :display_name "Venues")] - ;; ok, now give all the Fields `?` as their `database_type`. (This is what the DB migration does for existing - ;; Fields) - (db/update-where! Field {:table_id (u/get-id venues-table)}, :database_type "?") - ;; now sync the DB again - (let [{:keys [step-info task-history]} (sut/sync-database! "sync-fields" db)] - [(sut/only-step-keys step-info) - (:task_details task-history) - ;; The database_type of these Fields should get set to the correct types. Let's see... - (set (map (partial into {}) - (db/select [Field :name :database_type] :table_id (u/get-id venues-table))))])))) +(deftest update-database-type-test + (testing "make sure that if a driver reports back a different database-type the Field gets updated accordingly" + (mt/with-temp Database [db (select-keys (mt/db) [:details :engine])] + (sync/sync-database! db) + (let [venues-table (Table :db_id (u/the-id db), :display_name "Venues")] + ;; ok, now give all the Fields `?` as their `database_type`. (This is what the DB migration does for existing + ;; Fields) + (db/update-where! Field {:table_id (u/the-id venues-table)}, :database_type "?") + ;; now sync the DB again + (let [{:keys [step-info task-history]} (sync.util-test/sync-database! "sync-fields" db)] + (is (= {:total-fields 16, :updated-fields 6} + (sync.util-test/only-step-keys step-info))) + (is (= {:total-fields 16, :updated-fields 6} + (:task_details task-history))) + (testing "The database_type of these Fields should get set to the correct types. Let's see..." + (is (= #{{:name "PRICE", :database_type "INTEGER"} + {:name "CATEGORY_ID", :database_type "INTEGER"} + {:name "ID", :database_type "BIGINT"} + {:name "LATITUDE", :database_type "DOUBLE"} + {:name "LONGITUDE", :database_type "DOUBLE"} + {:name "NAME", :database_type "VARCHAR"}} + (set (mt/derecordize + (db/select [Field :name :database_type] :table_id (u/the-id venues-table)))))))))))) -;; make sure that if a driver reports back a different base-type the Field gets updated accordingly -(expect - (concat - (repeat 2 {:updated-fields 16, :total-fields 16}) - (repeat 2 {:updated-fields 6, :total-fields 16}) - [#{{:name "NAME", :base_type :type/Text} - {:name "LATITUDE", :base_type :type/Float} - {:name "PRICE", :base_type :type/Integer} - {:name "ID", :base_type :type/BigInteger} - {:name "LONGITUDE", :base_type :type/Float} - {:name "CATEGORY_ID", :base_type :type/Integer}}]) - ;; create a copy of the sample dataset :D - (tt/with-temp Database [db (select-keys (data/db) [:details :engine])] - (let [{new-step-info :step-info, new-task-history :task-history} (sut/sync-database! "sync-fields" db) - venues-table (Table :db_id (u/get-id db), :display_name "Venues")] - ;; ok, now give all the Fields `:type/*` as their `base_type` - (db/update-where! Field {:table_id (u/get-id venues-table)}, :base_type "type/*") - ;; now sync the DB again - (let [{after-step-info :step-info, after-task-history :task-history} (sut/sync-database! "sync-fields" db)] - [(sut/only-step-keys new-step-info) - (:task_details new-task-history) - (sut/only-step-keys after-step-info) - (:task_details after-task-history) - ;; The database_type of these Fields should get set to the correct types. Let's see... - (set (map (partial into {}) - (db/select [Field :name :base_type] :table_id (u/get-id venues-table))))])))) +(deftest update-base-type-test + (testing "make sure that if a driver reports back a different base-type the Field gets updated accordingly" + (mt/with-temp Database [db (select-keys (mt/db) [:details :engine])] + (let [{new-step-info :step-info, new-task-history :task-history} (sync.util-test/sync-database! "sync-fields" db) + venues-table (Table :db_id (u/the-id db), :display_name "Venues")] + ;; ok, now give all the Fields `:type/*` as their `base_type` + (db/update-where! Field {:table_id (u/the-id venues-table)}, :base_type "type/*") + ;; now sync the DB again + (let [{after-step-info :step-info, after-task-history :task-history} (sync.util-test/sync-database! "sync-fields" db)] + (is (= {:updated-fields 16, :total-fields 16} + (sync.util-test/only-step-keys new-step-info))) + (is (= {:updated-fields 16, :total-fields 16} + (:task_details new-task-history))) + (is (= {:updated-fields 6, :total-fields 16} + (sync.util-test/only-step-keys after-step-info))) + (is (= {:updated-fields 6, :total-fields 16} + (:task_details after-task-history))) + (testing "The database_type of these Fields should get set to the correct types. Let's see..." + (is (= #{{:name "CATEGORY_ID", :base_type :type/Integer} + {:name "LONGITUDE", :base_type :type/Float} + {:name "PRICE", :base_type :type/Integer} + {:name "LATITUDE", :base_type :type/Float} + {:name "NAME", :base_type :type/Text} + {:name "ID", :base_type :type/BigInteger}} + (set (mt/derecordize + (db/select [Field :name :base_type] :table_id (u/the-id venues-table)))))))))))) diff --git a/test/metabase/task_test.clj b/test/metabase/task_test.clj index 05a8a623a3f6d..5e7e715b799c0 100644 --- a/test/metabase/task_test.clj +++ b/test/metabase/task_test.clj @@ -4,11 +4,9 @@ [clojurewerkz.quartzite.schedule.cron :as cron] [clojurewerkz.quartzite.scheduler :as qs] [clojurewerkz.quartzite.triggers :as triggers] - [expectations :refer [expect]] [metabase.task :as task] [metabase.test :as mt] [metabase.test.fixtures :as fixtures] - [metabase.test.util :as tu] [metabase.util.schema :as su] [schema.core :as s]) (:import [org.quartz CronTrigger JobDetail])) @@ -43,7 +41,7 @@ (cron/with-misfire-handling-instruction-ignore-misfires))))) (defn- do-with-temp-scheduler-and-cleanup [f] - (tu/with-temp-scheduler + (mt/with-temp-scheduler (try (f) (finally @@ -58,31 +56,31 @@ {:cron-expression (.getCronExpression trigger) :misfire-instruction (.getMisfireInstruction trigger)}))) -;; can we schedule a job? -(expect - #{{:cron-expression "0 0 * * * ? *" - :misfire-instruction CronTrigger/MISFIRE_INSTRUCTION_DO_NOTHING}} - (with-temp-scheduler-and-cleanup - (task/schedule-task! (job) (trigger-1)) - (triggers))) +(deftest schedule-job-test + (testing "can we schedule a job?" + (with-temp-scheduler-and-cleanup + (task/schedule-task! (job) (trigger-1)) + (is (= #{{:cron-expression "0 0 * * * ? *" + :misfire-instruction CronTrigger/MISFIRE_INSTRUCTION_DO_NOTHING}} + (triggers)))))) -;; does scheduling a job a second time work without throwing errors? -(expect - #{{:cron-expression "0 0 * * * ? *" - :misfire-instruction CronTrigger/MISFIRE_INSTRUCTION_DO_NOTHING}} - (with-temp-scheduler-and-cleanup - (task/schedule-task! (job) (trigger-1)) - (task/schedule-task! (job) (trigger-1)) - (triggers))) +(deftest reschedule-job-test + (testing "does scheduling a job a second time work without throwing errors?" + (with-temp-scheduler-and-cleanup + (task/schedule-task! (job) (trigger-1)) + (task/schedule-task! (job) (trigger-1)) + (is (= #{{:cron-expression "0 0 * * * ? *" + :misfire-instruction CronTrigger/MISFIRE_INSTRUCTION_DO_NOTHING}} + (triggers)))))) -;; does scheduling a job with a *new* trigger replace the original? (can we reschedule a job?) -(expect - #{{:cron-expression "0 0 6 * * ? *" - :misfire-instruction CronTrigger/MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY}} - (with-temp-scheduler-and-cleanup - (task/schedule-task! (job) (trigger-1)) - (task/schedule-task! (job) (trigger-2)) - (triggers))) +(deftest reschedule-and-replace-job-test + (testing "does scheduling a job with a *new* trigger replace the original? (can we reschedule a job?)" + (with-temp-scheduler-and-cleanup + (task/schedule-task! (job) (trigger-1)) + (task/schedule-task! (job) (trigger-2)) + (is (= #{{:cron-expression "0 0 6 * * ? *" + :misfire-instruction CronTrigger/MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY}} + (triggers)))))) (deftest scheduler-info-test (testing "Make sure scheduler-info doesn't explode and returns info in the general shape we expect" diff --git a/test/metabase/test/data/h2.clj b/test/metabase/test/data/h2.clj index 2f3dda36865b9..cca0dd3b2a540 100644 --- a/test/metabase/test/data/h2.clj +++ b/test/metabase/test/data/h2.clj @@ -18,18 +18,22 @@ (defonce ^:private h2-test-dbs-created-by-this-instance (atom #{})) -;; For H2, test databases are all in-memory, which don't work if they're saved from a different REPL session or the -;; like. So delete any 'stale' in-mem DBs from the application DB when someone calls `get-or-create-database!` as -;; needed. +(defn- destroy-test-database-if-created-by-another-instance! + "For H2, test databases are all in-memory, which don't work if they're saved from a different REPL session or the + like. So delete any 'stale' in-mem DBs from the application DB when someone calls `get-or-create-database!` as + needed." + [database-name] + (when-not (contains? @h2-test-dbs-created-by-this-instance database-name) + (locking h2-test-dbs-created-by-this-instance + (when-not (contains? @h2-test-dbs-created-by-this-instance database-name) + (mdb/setup-db!) ; if not already setup + (db/delete! Database :engine "h2", :name database-name) + (swap! h2-test-dbs-created-by-this-instance conj database-name))))) + (defmethod data.impl/get-or-create-database! :h2 [driver dbdef] (let [{:keys [database-name], :as dbdef} (tx/get-dataset-definition dbdef)] - ;; don't think we need to bother making this super-threadsafe because REPL usage and tests are more or less - ;; single-threaded - (when (not (contains? @h2-test-dbs-created-by-this-instance database-name)) - (mdb/setup-db!) ; if not already setup - (db/delete! Database :engine "h2", :name database-name) - (swap! h2-test-dbs-created-by-this-instance conj database-name)) + (destroy-test-database-if-created-by-another-instance! database-name) ((get-method data.impl/get-or-create-database! :default) driver dbdef))) (doseq [[base-type database-type] {:type/BigInteger "BIGINT" diff --git a/test/metabase/test/data/impl.clj b/test/metabase/test/data/impl.clj index 5622d005ed6a5..dd6ec72e91f40 100644 --- a/test/metabase/test/data/impl.clj +++ b/test/metabase/test/data/impl.clj @@ -40,9 +40,12 @@ (let [driver (driver/the-driver driver)] (or (@locks driver) - (do - (swap! locks update driver #(or % (Object.))) - (@locks driver))))))) + (locking driver->create-database-lock + (or + (@locks driver) + (do + (swap! locks update driver #(or % (Object.))) + (@locks driver))))))))) (defmulti get-or-create-database! "Create DBMS database associated with `database-definition`, create corresponding Metabase Databases/Tables/Fields, diff --git a/test/metabase/util/embed_test.clj b/test/metabase/util/embed_test.clj index 36f19bb0cdbf3..4ec8e0b272fe2 100644 --- a/test/metabase/util/embed_test.clj +++ b/test/metabase/util/embed_test.clj @@ -1,20 +1,22 @@ (ns metabase.util.embed-test (:require [buddy.sign.jwt :as jwt] + [clojure.test :refer :all] [crypto.random :as crypto-random] - [expectations :refer :all] - [metabase.test.util :as tu] + [metabase.test :as mt] [metabase.util.embed :as embed])) (def ^:private ^String token-with-alg-none "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhZG1pbiI6dHJ1ZX0.3Dbtd6Z0yuSfw62fOzBGHyiL0BJp3pod_PZE-BBdR-I") -;; check that are token is in fact valid -(expect - {:admin true} - (jwt/unsign token-with-alg-none "")) +(deftest validate-token-test + (testing "check that are token is in fact valid" + (is (= {:admin true} + (jwt/unsign token-with-alg-none ""))))) -;; check that we disallow tokens signed with alg = none -(expect - clojure.lang.ExceptionInfo - (tu/with-temporary-setting-values [embedding-secret-key (crypto-random/hex 32)] - (embed/unsign token-with-alg-none))) +(deftest disallow-unsigned-tokens-test + (testing "check that we disallow tokens signed with alg = none" + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"JWT `alg` cannot be `none`" + (mt/with-temporary-setting-values [embedding-secret-key (crypto-random/hex 32)] + (embed/unsign token-with-alg-none)))))) diff --git a/test/metabase/util/encryption_test.clj b/test/metabase/util/encryption_test.clj index 3ab6f3cf37eb4..a40940c390adc 100644 --- a/test/metabase/util/encryption_test.clj +++ b/test/metabase/util/encryption_test.clj @@ -2,11 +2,10 @@ "Tests for encryption of Metabase DB details." (:require [clojure.string :as str] [clojure.test :refer :all] - [expectations :refer :all] [metabase.models.setting.cache :as setting.cache] + [metabase.test.initialize :as initialize] [metabase.test.util :as tu] - [metabase.util.encryption :as encryption] - [metabase.test.initialize :as initialize])) + [metabase.util.encryption :as encryption])) (defn do-with-secret-key [^String secret-key thunk] ;; flush the Setting cache so unencrypted values have to be fetched from the DB again @@ -26,45 +25,49 @@ (def ^:private secret (encryption/secret-key->hash "Orw0AAyzkO/kPTLJRxiyKoBHXa/d6ZcO+p+gpZO/wSQ=")) (def ^:private secret-2 (encryption/secret-key->hash "0B9cD6++AME+A7/oR7Y2xvPRHX3cHA2z7w+LbObd/9Y=")) -;; test that hashing a secret key twice gives you the same results -(expect - (= (vec (encryption/secret-key->hash "Toucans")) - (vec (encryption/secret-key->hash "Toucans")))) - -;; two different secret keys should have different results -(expect (not= (vec secret) - (vec secret-2))) - -;; test that we can encrypt a message. Should be base-64 -(expect - #"^[0-9A-Za-z/+]+=*$" - (encryption/encrypt secret "Hello!")) - -;; test that encrypting something twice gives you two different ciphertexts -(expect - (not= (encryption/encrypt secret "Hello!") - (encryption/encrypt secret "Hello!"))) - -;; test that we can decrypt something -(expect - "Hello!" - (encryption/decrypt secret (encryption/encrypt secret "Hello!"))) - -;; trying to decrypt something with the wrong key with `decrypt` should throw an Exception -(expect - Exception - (encryption/decrypt secret-2 (encryption/encrypt secret "WOW"))) - -;; trying to `maybe-decrypt` something that's not encrypted should return it as-is -(expect - "{\"a\":100}" - (encryption/maybe-decrypt secret "{\"a\":100}")) - -;; trying to decrypt something that is encrypted with the wrong key with `maybe-decrypt` should return the ciphertext... -(let [original-ciphertext (encryption/encrypt secret "WOW")] - (expect - original-ciphertext - (encryption/maybe-decrypt secret-2 original-ciphertext))) +(deftest repeatable-hashing-test + (testing "test that hashing a secret key twice gives you the same results" + (is (= (vec (encryption/secret-key->hash "Toucans")) + (vec (encryption/secret-key->hash "Toucans")))))) + +(deftest unique-hashes-test + (testing (is (not= (vec secret) + (vec secret-2))))) + +(deftest hash-pattern-test + (is (re= #"^[0-9A-Za-z/+]+=*$" + (encryption/encrypt secret "Hello!")))) + +(deftest hashing-isnt-idempotent-test + (testing "test that encrypting something twice gives you two different ciphertexts" + (is (not= (encryption/encrypt secret "Hello!") + (encryption/encrypt secret "Hello!"))))) + +(deftest decrypt-test + (testing "test that we can decrypt something" + (is (= "Hello!" + (encryption/decrypt secret (encryption/encrypt secret "Hello!")))))) + +(deftest exception-with-wrong-decryption-key-test + (testing "trying to decrypt something with the wrong key with `decrypt` should throw an Exception" + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Message seems corrupt or manipulated" + (encryption/decrypt secret-2 (encryption/encrypt secret "WOW")))))) + +(deftest maybe-decrypt-not-encrypted-test + (testing "trying to `maybe-decrypt` something that's not encrypted should return it as-is" + (is (= "{\"a\":100}" + (encryption/maybe-decrypt secret "{\"a\":100}"))) + (is (= "abc" + (encryption/maybe-decrypt secret "abc"))))) + +(deftest maybe-decrypt-with-wrong-key-test + (testing (str "trying to decrypt something that is encrypted with the wrong key with `maybe-decrypt` should return " + "the ciphertext...") + (let [original-ciphertext (encryption/encrypt secret "WOW")] + (is (= original-ciphertext + (encryption/maybe-decrypt secret-2 original-ciphertext)))))) (defn- includes-encryption-warning? [log-messages] (some (fn [[level _ message]] @@ -73,21 +76,11 @@ "MB_ENCRYPTION_SECRET_KEY? Message seems corrupt or manipulated.")))) log-messages)) -(expect - (includes-encryption-warning? - (tu/with-log-messages-for-level :warn - (encryption/maybe-decrypt secret-2 (encryption/encrypt secret "WOW"))))) - -;; Something obviously not encrypted should avoiding trying to decrypt it (and thus not log an error) -(expect - [] - (tu/with-log-messages-for-level :warn - (encryption/maybe-decrypt secret "abc"))) - -;; Something obviously not encrypted should return the original string -(expect - "abc" - (encryption/maybe-decrypt secret "abc")) +(deftest no-errors-for-unencrypted-test + (testing "Something obviously not encrypted should avoiding trying to decrypt it (and thus not log an error)" + (is (= [] + (tu/with-log-messages-for-level :warn + (encryption/maybe-decrypt secret "abc")))))) (def ^:private fake-ciphertext "AES+CBC's block size is 16 bytes and the tag length is 32 bytes. This is a string of characters that is the same @@ -100,9 +93,12 @@ "decrypted. If unable to decrypt it, log a warning.") (is (includes-encryption-warning? (tu/with-log-messages-for-level :warn - (encryption/maybe-decrypt secret fake-ciphertext)))))) + (encryption/maybe-decrypt secret fake-ciphertext)))) + (is (includes-encryption-warning? + (tu/with-log-messages-for-level :warn + (encryption/maybe-decrypt secret-2 (encryption/encrypt secret "WOW"))))))) -;; Something that is not encrypted, but might be should return the original text -(expect - fake-ciphertext - (encryption/maybe-decrypt secret fake-ciphertext)) +(deftest possibly-encrypted-test + (testing "Something that is not encrypted, but might be should return the original text" + (is (= fake-ciphertext + (encryption/maybe-decrypt secret fake-ciphertext))))) diff --git a/test/metabase/util/password_test.clj b/test/metabase/util/password_test.clj index 579a6a979ef31..8ff4614f87565 100644 --- a/test/metabase/util/password_test.clj +++ b/test/metabase/util/password_test.clj @@ -1,6 +1,5 @@ (ns metabase.util.password-test - (:require [expectations :refer [expect]] - [clojure.test :refer :all] + (:require [clojure.test :refer :all] [metabase.util.password :as pwu])) ;; Password Complexity testing