penpot/frontend/test/frontend_tests/data/workspace_notifications_test.cljs
alonso.torres e4e03b9c23 ✨ Tell repairs, damaged data and other files apart in save notices
The notice of a rejected save always said the same thing. It did not
name the file when the user had moved to another one, gave no hint when
the page held data that was already invalid, and replaced the notice of
a backend repair still on screen, since the store holds one toast.

The backend flags a rejection whose first commit touches pages or
components that failed validation before it (:preexisting-damage). The
rejection notice names a file other than the open one, says when the
data was damaged before, and folds into a repair notice still on
screen; a later repair notice folds a rejection notice in the same way.
The notice says that the latest changes were undone, no longer that the
rest of the work is saved.
2026-11-05 10:56:46 +01:00

263 lines
12 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.workspace-notifications-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.persistence :as dps]
[app.main.data.websocket :as dws]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.notifications :as dwntf]
[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- file-change-msg
[extra]
(merge {:type :file-change
:profile-id (uuid/next)
:file-id (uuid/next)
:session-id (uuid/next)
:revn 1
:vern 0
:changes []}
extra))
(defn- ^:async watch-file-change
[msg]
(let [events (atom [])]
(await (async/observe (ptk/watch (dwntf/handle-file-change msg) {} (rx/empty))
:on-next #(swap! events conj %)))
@events))
(defn- ^:async shown-notice
"The notification the watch of `event` shows on `state`."
[event state]
(let [events (atom [])]
(await (async/observe (ptk/watch event state (rx/empty))
:on-next #(swap! events conj %)))
(some #(when (= ::ntf/show (ptk/type %))
(:notification (ptk/update % {})))
@events)))
(t/deftest ^:async repaired-file-change-applies-and-notifies
(let [events (await (watch-file-change (file-change-msg {:repaired true})))
notice (await (shown-notice (second events) {}))]
(t/is (= [::dch/commit ::dps/notify-repaired] (mapv ptk/type events)))
(t/is (= :info (:level notice)))
(t/is (nil? (:timeout notice)) "the toast stays until the user closes it")
(t/is (= dps/file-repaired-tag (:tag notice)))
(t/is (= (i18n/tr "workspace.notifications.file-repaired") (:content notice)))))
;; Scenario: a save of the open file came back rejected and its notice is
;; still on screen when the backend repairs the file. The store holds one
;; toast. Proves: the repair notice tells both and keeps the report link.
(t/deftest ^:async a-repair-notice-keeps-a-rejection-notice-on-screen
(let [link {:label "report.txt" :callback identity}
notice (await (shown-notice (dps/notify-repaired)
{:notification {:content "rejected"
:tag dps/rejected-tag
:links [link]}}))]
(t/is (= (i18n/tr "workspace.notifications.file-repaired-changes-undone")
(:content notice)))
(t/is (= :warning (:level notice)))
(t/is (= dps/repaired-rejected-tag (:tag notice)))
(t/is (= [link] (:links notice)))
(t/is (nil? (:timeout notice)))))
(t/deftest ^:async ordinary-file-change-does-not-notify
(let [events (await (watch-file-change (file-change-msg {})))]
(t/is (= [::dch/commit] (mapv ptk/type events)))))
(defn- restored-events
"Events the watch of `handle-file-restored` emits for `msg` on `state`."
[state msg]
(let [events (atom [])]
(-> (async/observe (ptk/watch (dwntf/handle-file-restored msg) state nil)
:on-next #(swap! events conj %))
(.then (fn [_] @events)))))
(defn- shown-modal
[events]
(some #(when (= ::modal/show-modal (ptk/type %))
(::modal/modal (ptk/update % {})))
events))
;; Scenario: a peer restores an earlier version of the open file. The file
;; reloads and a modal names the peer. Proves: the reload is not silent.
(t/deftest ^:async a-peer-restore-reloads-and-names-the-peer
(let [file-id (uuid/next)
session-id (uuid/next)
profile-id (uuid/next)
state {:current-file-id file-id
:files {file-id {:id file-id :vern 1}}
:workspace-presence {session-id {:profile-id profile-id}}
:profiles {profile-id {:id profile-id :fullname "Ada"}}}
events (await (restored-events state {:type :file-restored
:file-id file-id
:session-id session-id
:vern 2}))
modal (shown-modal events)]
(t/is (some #(= ::dw/reload-current-file (ptk/type %)) events))
(t/is (= :file-restored (:type modal)))
(t/is (= "Ada" (get-in modal [:props :fullname])))
(t/is (false? (get-in modal [:props :unsaved])))))
;; Scenario: a peer restores a version they named, while this tab has a
;; rename not saved yet and the restorer has no presence. Proves: the modal
;; names the restorer by profile and the version by its label, and says the
;; unsaved rename is lost.
(t/deftest ^:async a-peer-restore-names-the-version-and-the-lost-edits
(let [file-id (uuid/next)
profile-id (uuid/next)
created-at (ct/now)
commit-id (uuid/next)
state {:current-file-id file-id
:files {file-id {:id file-id :vern 1}}
:profiles {profile-id {:id profile-id :fullname "Ada"}}
:persistence {:queue [commit-id]
:index {commit-id {:id commit-id
:file-id file-id
:file-vern 1
:commits [{:redo-changes [{:type :mod-page}]}]}}}}
events (await (restored-events state {:type :file-restored
:file-id file-id
:session-id (uuid/next)
:profile-id profile-id
:label "Hand-off v2"
:created-at created-at
:vern 2}))
props (:props (shown-modal events))]
(t/is (= "Ada" (:fullname props)))
(t/is (= "Hand-off v2" (:label props)))
(t/is (= created-at (:created-at props)))
(t/is (true? (:unsaved props)))))
;; Scenario: the restoring session left before its message arrived, so it
;; has no presence. Proves: the modal still shows, without a name.
(t/deftest ^:async a-restore-from-an-unknown-session-shows-no-name
(let [file-id (uuid/next)
state {:current-file-id file-id
:files {file-id {:id file-id :vern 1}}}
events (await (restored-events state {:type :file-restored
:file-id file-id
:session-id (uuid/next)
:vern 2}))
modal (shown-modal events)]
(t/is (= :file-restored (:type modal)))
(t/is (nil? (get-in modal [:props :fullname])))))
;; Scenario: the file already shows the restored version. Proves: no reload
;; and no modal for a version the user already sees.
(t/deftest ^:async a-restore-of-the-loaded-version-does-nothing
(let [file-id (uuid/next)
state {:current-file-id file-id
:files {file-id {:id file-id :vern 2}}}
events (await (restored-events state {:type :file-restored
:file-id file-id
:session-id (uuid/next)
:vern 2}))]
(t/is (empty? events))))
(defn- ^:async subscription-messages
"Runs `scenario` with a store over `team-id`, `dws/send` and the pointer
handler stubbed. `scenario` gets the store and a function that delivers
a peer's pointer message for a file. Resolves to the messages sent over
the websocket and the number of handled pointer messages."
[team-id scenario]
(let [sent (atom [])
handled (atom 0)
result (atom nil)
store (ptk/store {:state {:profile-id (uuid/next)
:session-id (uuid/next)
:teams {team-id {:id team-id}}}
:on-error #(t/is false (str %))})
pointer (fn [file-id]
(ptk/emit! store (ptk/data-event ::dws/message
{:type :pointer-update
:topic file-id
:file-id file-id
:session-id (uuid/next)})))]
(await
(mock/with-mocks*
{dws/send (mock/stub (fn [msg]
(swap! sent conj msg)
(ptk/data-event ::sent msg)))
dwntf/handle-pointer-update (mock/stub (fn [_]
(swap! handled inc)
(ptk/data-event ::handled {})))}
(try
(await (scenario store pointer))
;; Taken before the store ends, which ends the subscriptions too
(reset! result {:sent (mapv #(select-keys % [:type :file-id]) @sent)
:handled @handled})
(finally
(rx/dispose! store)))))
@result))
;; Scenario: the open file reloads three times (a peer's restore, the
;; restorer's own version reload, the rollback fallback), each starting a
;; new subscription, and then a peer's message arrives. Proves: one live
;; subscription per open file, so each message is handled once, and no
;; unsubscribe ends the server's subscription of the reloaded file.
(t/deftest ^:async a-reload-replaces-the-file-subscription
(let [team-id (uuid/next)
file-id (uuid/next)
result (await
(subscription-messages
team-id
(^:async fn [store pointer]
(ptk/emit! store
(dwntf/initialize team-id file-id)
(dwntf/initialize team-id file-id)
(dwntf/initialize team-id file-id))
(await (async/settle))
(pointer file-id)
(await (async/settle)))))]
(t/is (= 1 (:handled result)) "the message is handled once")
(t/is (= 3 (count (filter #(= :subscribe-file (:type %)) (:sent result)))))
(t/is (not-any? #(= :unsubscribe-file (:type %)) (:sent result)))))
;; Scenario: the user leaves a file, and in another case opens another file
;; without leaving the first one. Proves: the subscription ends with an
;; unsubscribe of the file it was for, and the old file's messages are no
;; longer handled.
(t/deftest ^:async leaving-or-switching-files-ends-the-file-subscription
(let [team-id (uuid/next)
file-a (uuid/next)
file-b (uuid/next)
left (await
(subscription-messages
team-id
(^:async fn [store _]
(ptk/emit! store
(dwntf/initialize team-id file-a)
(dwntf/finalize file-a))
(await (async/settle)))))
switch (await
(subscription-messages
team-id
(^:async fn [store pointer]
(ptk/emit! store
(dwntf/initialize team-id file-a)
(dwntf/initialize team-id file-b))
(await (async/settle))
(pointer file-a)
(pointer file-b)
(await (async/settle)))))]
(t/is (= [{:type :unsubscribe-file :file-id file-a}]
(filterv #(= :unsubscribe-file (:type %)) (:sent left))))
(t/is (= [{:type :unsubscribe-file :file-id file-a}]
(filterv #(= :unsubscribe-file (:type %)) (:sent switch))))
(t/is (= 1 (:handled switch)) "only the new file's message is handled")))