penpot/frontend/test/frontend_tests/data/dashboard_test.cljs
alonso.torres 882645500b 🐛 Fix small save bugs found by the rollback test plan
- Ending a drag inside the frame the shapes already belong to sent an
  empty reparent save and bumped the revision. The move commits only
  when some shape changes parent.
- When a user restored a version, other sessions reloaded the file with
  no notice. They now see a dialog naming who restored which version
  (by its name, else its date), saying that later changes are no longer
  in the file and, when they had changes not saved yet, that those were
  lost.
- Exports drawn in the browser (PDF, SVG, and PNG when the WASM renderer
  draws it) and duplicating a file from the dashboard started at once,
  so an export could show changes a pending save then rejected, and a
  copy could miss queued edits. Both wait for the file's pending saves.
  A client export warns that it includes unsaved changes; a copy warns
  that it misses them.
2026-11-04 15:01:49 +01:00

267 lines
13 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.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 (= ::ntf/show (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 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)))))))