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))]
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/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/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/components/CollapseSection.info.js b/frontend/src/metabase/components/CollapseSection.info.js
new file mode 100644
index 0000000000000..e44df34673cde
--- /dev/null
+++ b/frontend/src/metabase/components/CollapseSection.info.js
@@ -0,0 +1,63 @@
+import React from "react";
+import CollapseSection from "metabase/components/CollapseSection";
+import Icon from "metabase/components/Icon";
+
+export const component = CollapseSection;
+export const category = "layout";
+export const description = `
+A collapsible section with a clickable header.
+`;
+
+export const examples = {
+ "Collapsed by default": (
+
+ foo foo foo foo foo foo foo foo
+
+ ),
+ "Settable collpased/expanded initial state": (
+
+ foo foo foo foo foo
+
+ ),
+ "Components in header": (
+
+
+ Component header
+
+ }
+ >
+ 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;
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/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;
+ }, {}),
+);
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 */}
-
-
{
- setValue(unsavedValue.length > 0 ? unsavedValue : null);
- focusChanged(false);
- }}
- >
- {savedValue.length > 0 ? "Update filter" : "Add filter"}
-
+
+ {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 */}
+
+ {
+ setValue(unsavedValue.length > 0 ? unsavedValue : null);
+ focusChanged(false);
+ }}
+ >
+ {savedValue.length > 0 ? "Update filter" : "Add filter"}
+
+
);
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 {
onDone(expression)}
>
{t`Done`}
diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx
index 08ca4c5b0fac1..9b9498fbd4380 100644
--- a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx
+++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx
@@ -109,6 +109,7 @@ export default class ExpressionEditorTextfield extends React.Component {
onChange: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
startRule: PropTypes.string.isRequired,
+ onBlankChange: PropTypes.func,
};
static defaultProps = {
@@ -300,6 +301,9 @@ export default class ExpressionEditorTextfield extends React.Component {
};
const isValid = expression !== undefined;
+ if (this.props.onBlankChange) {
+ this.props.onBlankChange(source.length === 0);
+ }
// don't show suggestions if
// * there's a selection
// * we're at the end of a valid expression, unless the user has typed another space
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/frontend/test/metabase/scenarios/question/filter.cy.spec.js b/frontend/test/metabase/scenarios/question/filter.cy.spec.js
index 33727bdf5cb2b..e59e190d9d3ac 100644
--- a/frontend/test/metabase/scenarios/question/filter.cy.spec.js
+++ b/frontend/test/metabase/scenarios/question/filter.cy.spec.js
@@ -526,7 +526,7 @@ describe("scenarios > question > filter", () => {
cy.findByText(AGGREGATED_FILTER);
});
- it("in a simple question should display popup for custom expression options (metabase#14341)", () => {
+ it("in a simple question should display popup for custom expression options (metabase#14341) (metabase#15244)", () => {
openProductsTable();
cy.findByText("Filter").click();
cy.findByText("Custom Expression").click();
@@ -543,6 +543,7 @@ describe("scenarios > question > filter", () => {
.click()
.type("contains(");
cy.findByText(/Checks to see if string1 contains string2 within it./i);
+ cy.findByRole("button", { name: "Done" }).should("not.be.disabled");
cy.get(".text-error").should("not.exist");
cy.findAllByText(/Expected one of these possible Token sequences:/i).should(
"not.exist",
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/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/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/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/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/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)
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/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/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/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/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"
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