diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index b0d3dee87b..c78c18cf96 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -1,14 +1,17 @@ -{:lint-as {status-im.utils.views/defview clojure.core/defn - status-im.utils.views/letsubs clojure.core/let - reagent.core/with-let clojure.core/let - status-im.utils.fx/defn clj-kondo.lint-as/def-catch-all - utils.re-frame/defn clj-kondo.lint-as/def-catch-all - quo.react/with-deps-check clojure.core/fn - quo.previews.preview/list-comp clojure.core/for - status-im.utils.styles/def clojure.core/def - status-im.utils.styles/defn clojure.core/defn - taoensso.tufte/defnp clojure.core/defn} +{:lint-as {status-im.utils.views/defview clojure.core/defn + status-im.utils.views/letsubs clojure.core/let + reagent.core/with-let clojure.core/let + status-im.utils.fx/defn clj-kondo.lint-as/def-catch-all + utils.re-frame/defn clj-kondo.lint-as/def-catch-all + quo.react/with-deps-check clojure.core/fn + quo.previews.preview/list-comp clojure.core/for + status-im.utils.styles/def clojure.core/def + status-im.utils.styles/defn clojure.core/defn + status-im.test-helpers/deftest-sub clojure.core/defn + taoensso.tufte/defnp clojure.core/defn} :linters {:invalid-arity {:skip-args [status-im.utils.fx/defn utils.re-frame/defn]} - ;;TODO remove number when this is fixed - ;;https://github.com/borkdude/clj-kondo/issues/867 - :unresolved-symbol {:exclude [PersistentPriorityMap.EMPTY number]}}} + ;; TODO remove number when this is fixed + ;; https://github.com/borkdude/clj-kondo/issues/867 + :unresolved-symbol {:exclude [PersistentPriorityMap.EMPTY + number + status-im.test-helpers/restore-app-db]}}} diff --git a/doc/new-guidelines.md b/doc/new-guidelines.md index b3b61e26fb..6989ef8c4e 100644 --- a/doc/new-guidelines.md +++ b/doc/new-guidelines.md @@ -346,6 +346,53 @@ keywords and concatenating them into a single string. (i18n/label :t/biometric-auth-error {:code error-code}) ``` +### Tests +#### Subscription tests + +Test [layer-3 subscriptions](https://day8.github.io/re-frame/subscriptions/) by +actually subscribing to them, so reframe's signal graph gets validated too. + +```clojure +;; bad +(defn user-recipes + [[current-user all-recipes location]] + ...) + +(re-frame/reg-sub + :user/recipes + :<- [:current-user] + :<- [:all-recipes] + :<- [:location] + user-recipes) + +(deftest user-recipes-test + (testing "builds list of recipes" + (let [current-user {...} + all-recipes {...} + location [...]] + (is (= expected (recipes [current-user all-recipes location])))))) + +;; good +(require '[status-im.test-helpers :as h]) + +(re-frame/reg-sub + :user/recipes + :<- [:current-user] + :<- [:all-recipes] + :<- [:location] + (fn [[current-user all-recipes location]] + ...)) + +(h/deftest-sub :user/recipes + [sub-name] + (testing "builds list of recipes" + (swap! rf-db/app-db assoc + :current-user {...} + :all-recipes {...} + :location [...]) + (is (= expected (rf/sub [sub-name]))))) +``` + ## Project Structure First, the bird's-eye view with some example ClojureScript files: diff --git a/src/status_im/test_helpers.clj b/src/status_im/test_helpers.clj new file mode 100644 index 0000000000..7e2943e820 --- /dev/null +++ b/src/status_im/test_helpers.clj @@ -0,0 +1,57 @@ +(ns status-im.test-helpers + (:require [clojure.spec.alpha :as s] + [clojure.string :as string] + [clojure.walk :as walk])) + +(defn- subscription-name->test-name + [sub-name] + (->> [(namespace sub-name) + (name sub-name) + "test"] + (remove nil?) + (map #(string/replace % #"\." "-")) + (string/join "-"))) + +(defmacro ^:private testing-subscription + [description & body] + `(cljs.test/testing ~description + (restore-app-db (fn [] ~@body)))) + +(s/fdef deftest-sub + :args (s/cat :sub-name keyword? + :args (s/coll-of symbol? :count 1) + :body (s/* any?))) + +(defmacro deftest-sub + "Defines a test based on `sub-name`, executes `body` and restores the app db. + + Any usage of the `cljs.test/testing` macro inside `body` will be modified to + also make sure the app db is restored and the subscription cache is reset. + + Expressions in `body` will have access to `sub-name`, which should be used to + avoid needlessly repeating the subscription name. + + Example: + + ```clojure + (require '[status-im.test-helpers :as h]) + + (h/deftest-sub :wallet/sorted-tokens + [sub-name] + (testing \"sorts tokens by name, lowercased\" + ;; Arrange + (swap! rf-db/app-db assoc-in [] ) + + ;; Act and Assert + (is (= (rf/sub [sub-name]))))) + ```" + [sub-name args & body] + `(let [sub-name# ~sub-name] + (cljs.test/deftest ~(symbol (subscription-name->test-name sub-name)) + (let [~args [sub-name#]] + (restore-app-db + (fn [] + ~@(clojure.walk/postwalk-replace + {'cljs.test/testing `testing-subscription + 'testing `testing-subscription} + body))))))) diff --git a/src/status_im/test_helpers.cljs b/src/status_im/test_helpers.cljs index be62cbbb87..006b16d802 100644 --- a/src/status_im/test_helpers.cljs +++ b/src/status_im/test_helpers.cljs @@ -5,10 +5,12 @@ Avoid coupling this namespace with particularities of the Status' domain, thus prefer to use it for more general purpose concepts, such as the re-frame event layer." + (:require-macros status-im.test-helpers) (:require [re-frame.core :as rf] [re-frame.db :as rf-db] [re-frame.events :as rf-events] [re-frame.registrar :as rf-registrar] + [re-frame.subs :as rf-subs] [taoensso.timbre :as log])) (defn db @@ -100,3 +102,15 @@ :fn (fn [{:keys [vargs level]}] (swap! logs conj {:args vargs :level level}))})] (f logs)))) + +(defn restore-app-db + "Saves current app DB, calls `f` and restores the original app DB. + + Always clears the subscription cache after calling `f`." + [f] + (rf-subs/clear-subscription-cache!) + (let [original-db @rf-db/app-db] + (try + (f) + (finally + (reset! rf-db/app-db original-db))))) diff --git a/src/status_im2/subs/activity_center_test.cljs b/src/status_im2/subs/activity_center_test.cljs new file mode 100644 index 0000000000..f868b1b132 --- /dev/null +++ b/src/status_im2/subs/activity_center_test.cljs @@ -0,0 +1,16 @@ +(ns status-im2.subs.activity-center-test + (:require [cljs.test :refer [is testing]] + [re-frame.db :as rf-db] + [status-im.test-helpers :as h] + status-im2.subs.activity-center + [utils.re-frame :as rf])) + +(h/deftest-sub :activity-center/filter-status-unread-enabled? + [sub-name] + (testing "returns true when filter status is unread" + (swap! rf-db/app-db assoc-in [:activity-center :filter :status] :unread) + (is (true? (rf/sub [sub-name])))) + + (testing "returns false when filter status is not unread" + (swap! rf-db/app-db assoc-in [:activity-center :filter :status] :all) + (is (false? (rf/sub [sub-name]))))) diff --git a/src/status_im2/subs/communities_test.cljs b/src/status_im2/subs/communities_test.cljs index ed8d62c13b..2885d4d24c 100644 --- a/src/status_im2/subs/communities_test.cljs +++ b/src/status_im2/subs/communities_test.cljs @@ -1,40 +1,76 @@ (ns status-im2.subs.communities-test - (:require [cljs.test :refer [deftest is testing]] - [status-im2.subs.communities :as subs])) + (:require [cljs.test :refer [is testing use-fixtures]] + [re-frame.db :as rf-db] + [status-im.test-helpers :as h] + status-im2.subs.communities + [utils.re-frame :as rf])) -(deftest community->home-item-test - (testing "has unread messages" - (is (= {:name "name-1" - :muted? true - :unread-messages? true - :unread-mentions-count 5 - :community-icon "icon-1"} - (subs/community->home-item - {:name "name-1" - :muted true - :images {:thumbnail {:uri "icon-1"}}} - {:unviewed-messages-count 1 - :unviewed-mentions-count 5})))) - (testing "no unread messages" - (is (= {:name "name-2" - :muted? false - :unread-messages? false - :unread-mentions-count 5 - :community-icon "icon-2"} - (subs/community->home-item - {:name "name-2" - :muted false - :images {:thumbnail {:uri "icon-2"}}} - {:unviewed-messages-count 0 - :unviewed-mentions-count 5}))))) +(use-fixtures :each + {:before #(reset! rf-db/app-db {:communities/enabled? true})}) -(deftest calculate-unviewed-counts-test - (let [chats [{:unviewed-messages-count 1 - :unviewed-mentions-count 2} - {:unviewed-messages-count 3 - :unviewed-mentions-count 0} - {:unviewed-messages-count 2 - :unviewed-mentions-count 1}]] - (is (= {:unviewed-messages-count 6 - :unviewed-mentions-count 3} - (subs/calculate-unviewed-counts chats))))) +(def community-id "0x1") + +(h/deftest-sub :communities + [sub-name] + (testing "returns empty vector if flag is disabled" + (swap! rf-db/app-db assoc :communities/enabled? false) + (is (= [] (rf/sub [sub-name])))) + + (testing "returns raw communities if flag is enabled" + (let [raw-communities {"0x1" {:id "0x1"}}] + (swap! rf-db/app-db assoc + :communities/enabled? true + :communities raw-communities) + (is (= raw-communities (rf/sub [sub-name])))))) + +(h/deftest-sub :communities/section-list + [sub-name] + (testing "builds sections using the first community name char (uppercased)" + (swap! rf-db/app-db assoc :communities + {"0x1" {:name "civilized monkeys"} + "0x2" {:name "Civilized rats"}}) + (is (= [{:title "C" + :data [{:name "civilized monkeys"} + {:name "Civilized rats"}]}] + (rf/sub [sub-name])))) + + (testing "sorts by section ascending" + (swap! rf-db/app-db assoc :communities + {"0x3" {:name "Memorable"} + "0x1" {:name "Civilized monkeys"}}) + (is (= [{:title "C" :data [{:name "Civilized monkeys"}]} + {:title "M" :data [{:name "Memorable"}]}] + (rf/sub [sub-name])))) + + (testing "builds default section for communities without a name" + (swap! rf-db/app-db assoc :communities + {"0x2" {:id "0x2"} + "0x1" {:id "0x1"}}) + (is (= [{:title "" + :data [{:id "0x2"} + {:id "0x1"}]}] + (rf/sub [sub-name]))))) + +(h/deftest-sub :communities/unviewed-counts + [sub-name] + (testing "sums counts for a particular community" + (swap! rf-db/app-db assoc :chats + {"0x100" {:community-id community-id + :unviewed-mentions-count 3 + :unviewed-messages-count 2} + "0x101" {:community-id "0x2" + :unviewed-mentions-count 7 + :unviewed-messages-count 9} + "0x102" {:community-id community-id + :unviewed-mentions-count 5 + :unviewed-messages-count 1}}) + (is (= {:unviewed-messages-count 3 + :unviewed-mentions-count 8} + (rf/sub [sub-name community-id])))) + + (testing "defaults to zero when count keys are not present" + (swap! rf-db/app-db assoc :chats + {"0x100" {:community-id community-id}}) + (is (= {:unviewed-messages-count 0 + :unviewed-mentions-count 0} + (rf/sub [sub-name community-id]))))) diff --git a/src/status_im2/subs/subs_test.cljs b/src/status_im2/subs/subs_test.cljs index 6773feaf50..3418f871c6 100644 --- a/src/status_im2/subs/subs_test.cljs +++ b/src/status_im2/subs/subs_test.cljs @@ -1,9 +1,7 @@ (ns status-im2.subs.subs-test (:require [cljs.test :refer [deftest is testing]] [status-im2.subs.wallet.transactions :as wallet.transactions] - [status-im2.subs.onboarding :as onboarding] - [status-im.utils.money :as money] - [status-im2.subs.wallet.wallet :as wallet])) + [status-im2.subs.onboarding :as onboarding])) (def transactions [{:timestamp "1505912551000"} {:timestamp "1505764322000"} @@ -40,17 +38,3 @@ {"0x1" {:keycard-pairing "keycard-pairing-code"}}} {})] (is (= res "keycard-pairing-code"))))) - -(deftest test-balance-total-value - (is (= (wallet/get-balance-total-value - {:ETH (money/bignumber 1000000000000000000) - :SNT (money/bignumber 100000000000000000000) - :AST (money/bignumber 10000)} - {:ETH {:USD {:from "ETH", :to "USD", :price 677.91, :last-day 658.688}} - :SNT {:USD {:from "SNT", :to "USD", :price 0.1562, :last-day 0.15}} - :AST {:USD {:from "AST", :to "USD", :price 4, :last-day 3}}} - :USD - {:ETH 18 - :SNT 18 - :AST 4}) - 697.53))) \ No newline at end of file diff --git a/src/status_im2/subs/wallet/wallet_test.cljs b/src/status_im2/subs/wallet/wallet_test.cljs new file mode 100644 index 0000000000..47b51f14ab --- /dev/null +++ b/src/status_im2/subs/wallet/wallet_test.cljs @@ -0,0 +1,133 @@ +(ns status-im2.subs.wallet.wallet-test + (:require [cljs.test :refer [deftest is testing]] + [re-frame.db :as rf-db] + [status-im.test-helpers :as h] + [status-im.utils.money :as money] + [status-im2.subs.wallet.wallet :as wallet] + [utils.re-frame :as rf])) + +(def money-zero (money/bignumber 0)) +(def money-eth (money/bignumber 8000000000000000000)) +(def money-snt (money/bignumber 756000000000000000000)) +(def main-account-id "0x0Fbd") + +(def accounts + [{:address "0x0Fbd" + :name "Main account" + :hidden false + :removed false} + {:address "0x5B03" + :name "Secondary account" + :hidden false + :removed false}]) + +(def wallet + {:accounts {main-account-id + {:balance {:ETH money-eth :SNT money-snt} + :transactions {} + :max-block 0} + "0x5B03" + {:balance {:ETH money-eth :SNT money-snt} + :transactions {} + :max-block 10}}}) + +(def prices + {:ETH {:USD {:from "ETH" + :to "USD" + :price 1282.23}} + :SNT {:USD {:from "SNT" + :to "USD" + :price 0.0232}}}) + +(def tokens + {"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + {:address "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + :name "Ether" + :symbol :ETH + :decimals 18 + :chainId 1} + "0x744d70fdbe2ba4cf95131626614a1763df805b9e" + {:address "0x744d70fdbe2ba4cf95131626614a1763df805b9e" + :name "Status Network Token" + :symbol :SNT + :decimals 18 + :chainId 1}}) + +(h/deftest-sub :balances + [sub-name] + (swap! rf-db/app-db assoc + :multiaccount/accounts accounts + :wallet wallet) + (is (= [{:ETH money-eth + :SNT money-snt} + {:ETH money-eth + :SNT money-snt}] + (rf/sub [sub-name])))) + +(h/deftest-sub :wallet/token->decimals + [sub-name] + (swap! rf-db/app-db assoc :wallet/all-tokens tokens) + (is (= {:SNT 18 :ETH 18} + (rf/sub [sub-name])))) + +(deftest get-balance-total-value-test + (is (= 697.53 + (wallet/get-balance-total-value + {:ETH (money/bignumber 1000000000000000000) + :SNT (money/bignumber 100000000000000000000) + :AST (money/bignumber 10000)} + {:ETH {:USD {:from "ETH" :to "USD" :price 677.91 :last-day 658.688}} + :SNT {:USD {:from "SNT" :to "USD" :price 0.1562 :last-day 0.15}} + :AST {:USD {:from "AST" :to "USD" :price 4 :last-day 3}}} + :USD + {:ETH 18 + :SNT 18 + :AST 4})))) + +(h/deftest-sub :portfolio-value + [sub-name] + (testing "returns fallback value when balances and prices are not available" + (is (= "..." (rf/sub [sub-name])))) + + (testing "returns zero when balance is not positive" + (let [empty-wallet {:accounts {main-account-id + {:balance {:ETH money-zero + :SNT money-zero}}}}] + (swap! rf-db/app-db assoc + :multiaccount/accounts accounts + :prices prices + :wallet empty-wallet + :wallet/all-tokens tokens) + (is (= "0" (rf/sub [sub-name]))))) + + (testing "returns formatted value in the default USD currency" + (swap! rf-db/app-db assoc + :multiaccount/accounts accounts + :prices prices + :wallet wallet + :wallet/all-tokens tokens) + (is (= "20,550.76" (rf/sub [sub-name]))))) + +(h/deftest-sub :account-portfolio-value + [sub-name] + (testing "returns fallback value when balances and prices are not available" + (is (= "..." (rf/sub [sub-name])))) + + (testing "returns zero when balance is not positive" + (let [empty-wallet {:accounts {main-account-id + {:balance {:ETH money-zero + :SNT money-zero}}}}] + (swap! rf-db/app-db assoc + :multiaccount/accounts accounts + :prices prices + :wallet empty-wallet + :wallet/all-tokens tokens) + (is (= "0" (rf/sub [sub-name main-account-id]))))) + + (testing "returns formatted value in the default USD currency" + (swap! rf-db/app-db assoc + :multiaccount/accounts accounts + :prices prices + :wallet wallet + :wallet/all-tokens tokens) + (is (= "10,275.38" (rf/sub [sub-name main-account-id])))))