mirror of
https://github.com/penpot/penpot.git
synced 2026-11-08 11:04:44 +00:00
301 lines
15 KiB
Clojure
301 lines
15 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
|
|
|
(ns frontend-tests.data.dashboard-test
|
|
(:require
|
|
[app.common.uuid :as uuid]
|
|
[app.config :as cf]
|
|
[app.main.data.common :as dcm]
|
|
[app.main.data.dashboard :as dd]
|
|
[app.main.data.notifications :as ntf]
|
|
[app.main.data.persistence :as dps]
|
|
[app.main.data.team :as dtm]
|
|
[app.main.data.websocket :as dws]
|
|
[app.main.repo :as rp]
|
|
[app.main.router :as rt]
|
|
[app.util.i18n :as i18n]
|
|
[beicon.v2.core :as rx]
|
|
[cljs.test :as t :include-macros true]
|
|
[frontend-tests.helpers.async :as async]
|
|
[frontend-tests.helpers.mock :as mock]
|
|
[potok.v2.core :as ptk]))
|
|
(defn- tr-args-for-team-org-toast
|
|
"Runs handle-change-team-organization and returns recorded i18n/tr calls."
|
|
[notification organization]
|
|
(let [team-id (uuid/next)
|
|
current-url (str "https://penpot.example.com/#/dashboard/recent?team-id=" team-id)
|
|
state {:current-team-id team-id
|
|
:teams {team-id {:id team-id :name "Team-Apple"}}}
|
|
event (dcm/handle-change-team-organization
|
|
{:team {:id team-id :organization organization}
|
|
:notification notification})
|
|
tr-calls (atom [])]
|
|
(-> (mock/with-mocks*
|
|
{cf/flags (conj cf/flags :admin-console)
|
|
;; plain variadic fn: mock/stub does not cover IFn variadic dispatch
|
|
i18n/tr (fn [key & args]
|
|
(swap! tr-calls conj [key (vec args)])
|
|
(str key ":" (pr-str args)))
|
|
rp/cmd! (mock/stub
|
|
(fn [cmd params]
|
|
(if (= [:check-nitrate-sso
|
|
{:team-id team-id :url current-url}]
|
|
[cmd params])
|
|
(rx/of {:authorized true})
|
|
(rx/throw (ex-info "unexpected RPC" {:cmd cmd :params params})))))
|
|
rt/get-current-href (mock/stub (constantly current-url))}
|
|
(await (async/observe (ptk/watch event state nil)
|
|
:on-next (fn [ev]
|
|
(t/is (contains? #{::ntf/show ::dtm/fetch-teams}
|
|
(ptk/type ev)))))))
|
|
(.then (fn [_] @tr-calls))))) (t/deftest ^:async team-org-change-toast-names-team-when-joining-org
|
|
(let [tr-calls (await (tr-args-for-team-org-toast
|
|
"dashboard.team-belong-organization"
|
|
{:id (uuid/next) :name "OrgB"}))]
|
|
(t/is (= [["dashboard.team-belong-organization" ["Team-Apple" "OrgB"]]]
|
|
tr-calls))))
|
|
|
|
(t/deftest ^:async team-org-change-toast-names-team-when-leaving-org
|
|
(let [tr-calls (await (tr-args-for-team-org-toast
|
|
"dashboard.team-no-longer-belong-organization"
|
|
{:name "OrgA"}))]
|
|
(t/is (= [["dashboard.team-no-longer-belong-organization" ["Team-Apple" "OrgA"]]]
|
|
tr-calls))))
|
|
|
|
(t/deftest ^:async team-org-change-keeps-restrictions-met-of-the-same-organization
|
|
(let [team-id (uuid/next)
|
|
organization-id (uuid/next)
|
|
state {:teams {team-id {:id team-id
|
|
:organization {:id organization-id
|
|
:name "Acme"
|
|
:restrictions-met {:send-invitations false}}}}}
|
|
event (dcm/handle-change-team-organization
|
|
{:team {:id team-id
|
|
:organization {:id organization-id
|
|
:name "Acme 2"
|
|
:permissions {:send-invitations "owners"}}}
|
|
:notification nil})]
|
|
(await (mock/with-mocks*
|
|
{cf/flags (conj cf/flags :admin-console)}
|
|
(let [organization (get-in (ptk/update event state) [:teams team-id :organization])]
|
|
(t/is (= "Acme 2" (:name organization)))
|
|
(t/is (= {:send-invitations false} (:restrictions-met organization))))))))
|
|
|
|
(t/deftest ^:async team-org-change-refetches-teams-for-the-current-profile
|
|
(let [team-id (uuid/next)
|
|
state {:teams {team-id {:id team-id}}}
|
|
event (dcm/handle-change-team-organization
|
|
{:team {:id team-id :organization {:id (uuid/next) :name "Acme"}}
|
|
:notification nil})
|
|
types (atom [])]
|
|
(await (mock/with-mocks*
|
|
{cf/flags (conj cf/flags :admin-console)}
|
|
(await (async/observe (ptk/watch event state nil)
|
|
:on-next #(swap! types conj (ptk/type %))))))
|
|
(t/is (= [::dtm/fetch-teams] @types))))
|
|
|
|
(t/deftest ^:async duplicated-project-has-a-file-count-and-is-unpinned
|
|
(let [id (uuid/next)
|
|
copy-id (uuid/next)
|
|
team-id (uuid/next)
|
|
copied {:id copy-id :team-id team-id :name "Copy"}
|
|
state {:projects {id {:id id :name "Source" :count 9 :is-pinned true}}}
|
|
result (atom state)]
|
|
(await
|
|
(mock/with-mocks*
|
|
{rp/cmd! (mock/stub (fn [cmd _]
|
|
(->> (rx/of (case cmd
|
|
:duplicate-project copied
|
|
:get-project-files [{:id (uuid/next)} {:id (uuid/next)}]))
|
|
(rx/observe-on :async))))}
|
|
(await (async/observe (ptk/watch (dd/duplicate-project {:id id :name "Source"}) state (rx/empty))
|
|
:on-next #(swap! result (partial ptk/update %))))))
|
|
(t/is (= (assoc copied :count 2 :is-pinned false) (get-in @result [:projects copy-id])))))
|
|
|
|
(t/deftest moving-current-team-into-sso-organization-redirects
|
|
(t/async done
|
|
(let [team-id (uuid/next)
|
|
organization {:id (uuid/next) :name "OrgA"}
|
|
current-url (str "https://penpot.example.com/#/dashboard/recent?team-id=" team-id)
|
|
redirect-url "https://idp.example.com/authorize"
|
|
state {:current-team-id team-id}
|
|
event (dcm/handle-change-team-organization
|
|
{:team {:id team-id :organization organization}
|
|
:notification nil})]
|
|
(mock/with-mocks
|
|
{cf/flags (conj cf/flags :admin-console)
|
|
rp/cmd! (mock/stub
|
|
(fn [cmd params]
|
|
(if (= [:check-nitrate-sso
|
|
{:team-id team-id :url current-url}]
|
|
[cmd params])
|
|
(rx/of {:authorized false :redirect-uri redirect-url})
|
|
(rx/throw (ex-info "unexpected RPC" {:cmd cmd :params params})))))
|
|
rt/get-current-href (mock/stub (constantly current-url))}
|
|
(fn [done']
|
|
(->> (ptk/watch event state nil)
|
|
(rx/reduce conj [])
|
|
(rx/subs!
|
|
(fn [events]
|
|
(t/is (= [::rt/nav-raw] (mapv ptk/type events))))
|
|
(fn [error]
|
|
(t/is false (str "unexpected error: " error))
|
|
(done'))
|
|
(fn []
|
|
(done')))))
|
|
done))))
|
|
(t/deftest organization-sso-activation-redirects-current-team
|
|
(t/async done
|
|
(let [team-id (uuid/next)
|
|
organization-id (uuid/next)
|
|
current-url (str "https://penpot.example.com/#/workspace?team-id=" team-id)
|
|
redirect-url "https://idp.example.com/authorize"
|
|
state {:current-team-id team-id
|
|
:teams {team-id {:id team-id
|
|
:organization {:id organization-id}}}}
|
|
event (dcm/handle-organization-change-sso
|
|
{:organization-id organization-id})]
|
|
(mock/with-mocks
|
|
{cf/flags (conj cf/flags :admin-console)
|
|
rp/cmd! (mock/stub
|
|
(fn [cmd params]
|
|
(if (= [:check-nitrate-sso
|
|
{:team-id team-id :url current-url}]
|
|
[cmd params])
|
|
(rx/of {:authorized false :redirect-uri redirect-url})
|
|
(rx/throw (ex-info "unexpected RPC" {:cmd cmd :params params})))))
|
|
rt/get-current-href (mock/stub (constantly current-url))}
|
|
(fn [done']
|
|
(->> (ptk/watch event state nil)
|
|
(rx/reduce conj [])
|
|
(rx/subs!
|
|
(fn [events]
|
|
(t/is (= [::rt/nav-raw] (mapv ptk/type events))))
|
|
(fn [error]
|
|
(t/is false (str "unexpected error: " error))
|
|
(done'))
|
|
(fn []
|
|
(done')))))
|
|
done))))
|
|
|
|
(defn- sent-messages
|
|
"Runs the watch of `event` against `stream` with `dws/send` stubbed
|
|
and resolves to the messages it sends over the websocket."
|
|
[event state stream]
|
|
(let [sent (atom [])]
|
|
(-> (mock/with-mocks*
|
|
{dws/send (mock/stub (fn [msg] (swap! sent conj msg) msg))}
|
|
(await (async/observe (ptk/watch event state stream))))
|
|
(.then (fn [_] @sent)))))
|
|
|
|
(t/deftest ^:async dashboard-initialize-subscribes-to-team
|
|
(let [team-id (uuid/next)
|
|
state {:profile-id (uuid/next)
|
|
:teams {team-id {:id team-id :organization {:id (uuid/next)}}}}
|
|
sent (await (sent-messages (dd/initialize team-id) state (rx/empty)))]
|
|
(t/is (= [{:type :subscribe-team :team-id team-id}] sent))))
|
|
|
|
(t/deftest ^:async dashboard-initialize-resubscribes-on-reconnect
|
|
(let [team-id (uuid/next)
|
|
state {:profile-id (uuid/next)
|
|
:teams {team-id {:id team-id}}}
|
|
stream (rx/of (ptk/data-event ::dws/opened {})
|
|
(ptk/data-event ::dws/opened {}))
|
|
sent (await (sent-messages (dd/initialize team-id) state stream))]
|
|
(t/is (= 3 (count sent)))
|
|
(t/is (every? #(= {:type :subscribe-team :team-id team-id} %) sent))))
|
|
|
|
(t/deftest ^:async dashboard-initialize-accepts-team-organization-messages
|
|
(let [team-id (uuid/next)
|
|
org-id (uuid/next)
|
|
state {:profile-id (uuid/next)
|
|
:teams {team-id {:id team-id :organization {:id org-id}}}}
|
|
message (fn [topic]
|
|
(ptk/data-event ::dws/message
|
|
{:type :organization-change-sso
|
|
:topic topic
|
|
:organization-id org-id}))
|
|
stream (rx/of (message org-id) (message (uuid/next)))
|
|
processed (atom [])]
|
|
(await (mock/with-mocks*
|
|
{dws/send (mock/stub identity)}
|
|
(await (async/observe (ptk/watch (dd/initialize team-id) state stream)
|
|
:on-next #(swap! processed conj (ptk/type %))))))
|
|
(t/is (= 1 (count (filter #{::dcm/handle-organization-change-sso} @processed))))))
|
|
|
|
;; Scenario: the user duplicates a file whose workspace still has a change
|
|
;; waiting to be saved. Proves: the copy is requested once the save is done,
|
|
;; and only then is the user told, with a warning that the copy misses what
|
|
;; could not be saved, never replaced by the success toast.
|
|
(t/deftest ^:async duplicating-a-file-waits-for-its-pending-changes
|
|
(let [file-id (uuid/next)
|
|
commit-id (uuid/next)
|
|
calls (atom [])
|
|
answer (rx/subject)
|
|
store (ptk/store {:state {:files {}
|
|
:persistence {:queue (conj #queue [] commit-id)
|
|
:index {commit-id {:id commit-id :file-id file-id}}}}})]
|
|
(await
|
|
(mock/with-mocks*
|
|
{dps/force-persist-files-and-wait (mock/stub (fn [file-ids _]
|
|
(swap! calls conj [:wait file-ids])
|
|
(rx/empty)))
|
|
rp/cmd! (mock/stub (fn [cmd _]
|
|
(swap! calls conj [cmd])
|
|
(->> answer (rx/take 1) (rx/observe-on :async))))
|
|
i18n/tr (fn ([key] (str "translated:" key)) ([key & _] (str "translated:" key)))}
|
|
(ptk/emit! store (dd/duplicate-files [{:id file-id :name "File"}]))
|
|
(await (async/wait-for #(= 2 (count @calls)) "the copy is requested"))
|
|
(t/is (= [[:wait #{file-id}] [:duplicate-file]] @calls))
|
|
(t/is (nil? (:notification @store)) "nothing is told before the copy exists")
|
|
(rx/push! answer {:id (uuid/next)})
|
|
(await (async/wait-for #(some? (:notification @store)) "the user is told"))
|
|
(await (async/settle))
|
|
(t/is (= "translated:dashboard.duplicate-unsaved-changes"
|
|
(get-in @store [:notification :content])))
|
|
(t/is (= :warning (get-in @store [:notification :level])))))))
|
|
|
|
;; Scenario: the user duplicates a file with everything saved. Proves: the
|
|
;; success toast shows once the copy exists, not when it is requested.
|
|
(t/deftest ^:async duplicating-a-saved-file-tells-once-the-copy-exists
|
|
(let [file-id (uuid/next)
|
|
answer (rx/subject)
|
|
sent (atom 0)
|
|
store (ptk/store {:state {:files {}}})]
|
|
(await
|
|
(mock/with-mocks*
|
|
{dps/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty)))
|
|
rp/cmd! (mock/stub (fn [_ _]
|
|
(swap! sent inc)
|
|
(->> answer (rx/take 1) (rx/observe-on :async))))
|
|
i18n/tr (fn ([key] (str "translated:" key)) ([key & _] (str "translated:" key)))}
|
|
(ptk/emit! store (dd/duplicate-files [{:id file-id :name "File"}]))
|
|
(await (async/wait-for #(= 1 @sent) "the copy is requested"))
|
|
(t/is (nil? (:notification @store)))
|
|
(rx/push! answer {:id (uuid/next)})
|
|
(await (async/wait-for #(some? (:notification @store)) "the user is told"))
|
|
(t/is (= "translated:dashboard.success-duplicate-file"
|
|
(get-in @store [:notification :content])))
|
|
(t/is (= :success (get-in @store [:notification :level])))))))
|
|
|
|
;; Scenario: the server fails to copy the file. Proves: no success toast
|
|
;; claims a copy that does not exist.
|
|
(t/deftest ^:async a-failed-copy-tells-no-success
|
|
(let [errors (atom [])
|
|
store (ptk/store {:state {:files {}}
|
|
:on-error #(swap! errors conj %)})]
|
|
(await
|
|
(mock/with-mocks*
|
|
{dps/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty)))
|
|
rp/cmd! (mock/stub (fn [_ _]
|
|
(->> (rx/throw (ex-info "copy failed" {:type :internal}))
|
|
(rx/observe-on :async))))
|
|
i18n/tr (fn ([key] (str "translated:" key)) ([key & _] (str "translated:" key)))}
|
|
(ptk/emit! store (dd/duplicate-files [{:id (uuid/next) :name "File"}]))
|
|
(await (async/wait-for #(seq @errors) "the copy fails"))
|
|
(await (async/settle))
|
|
(t/is (nil? (:notification @store)))))))
|