mirror of
https://github.com/penpot/penpot.git
synced 2026-11-04 09:00:37 +00:00
Merge engine: a same-parent reorder, an addition under a parent main deleted, and a branch deleting a container main edited each lost work silently. Each now follows a policy read from backend/resources/app/branch-merge-policies.edn, re-read when the file changes, with the defaults: merge the reorder, and raise a conflict on the deleted parent or container. Added siblings keep the branch's order. A :mov-page index counts the target's full page list, a cleared page background reaches main, a per-attribute resolution can remove an attribute, and a token set reorder survives a rename on the other side. Conflict payloads name :main and :branch by document in both directions. Reorder detection costs nothing measurable on a page of 20,201 objects. Data versions: each op-log row records the data version it was written at, the branch replays its log in segments and migrates the document at each boundary, and the comparison base is migrated like main and the branch, so an upgrade no longer shows as false conflicts. Writers: a binfile import, an admin repair, file GC and a snapshot restore refuse a branch file, whose payload nothing reads, and the branch base pin cannot be renamed into a user snapshot. A branch save runs the ordinary media fix-up and logs the rewritten references. An update from main deletes the branch's copy of media main deleted and squashes no phantom media changes. Commands and UI: the pull-request commands publish their messages after the commit, the listing takes one connection per comparison and shows a warning instead of "in sync" when a comparison fails, materialize takes :branch-file-id, and a dashboard fetch that never answers ends in an error state instead of a spinner, with a refetch on reconnect. backend/dev/branch_merge_cases.clj builds one example file per merge policy case for demonstrating the alternatives. Two models wrote this change, claude-opus-5-5 and mimo-v2.6-pro, and the work switched between them, so the trailer reads mixed models. AI-assisted-by: mixed models
211 lines
9.3 KiB
Clojure
211 lines
9.3 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.project :as dpj]
|
|
[app.main.data.websocket :as dws]
|
|
[app.main.repo :as rp]
|
|
[app.main.router :as rt]
|
|
[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]))
|
|
|
|
(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))))))
|
|
|
|
(t/deftest never-answering-recent-files-fetch-resolves-placeholder-to-failure
|
|
(t/async done
|
|
(let [team-id (uuid/next)
|
|
state {:current-team-id team-id}]
|
|
(mock/with-mocks
|
|
{rp/fetch-timeout-ms 50
|
|
rp/cmd! (mock/stub (fn [_ _] (rx/subject)))}
|
|
(fn [done']
|
|
(->> (ptk/watch (dd/fetch-recent-files team-id) state nil)
|
|
(rx/reduce (fn [state event] (ptk/update event state)) state)
|
|
;; The guard fails the test instead of spinning when no
|
|
;; failure state arrives within the bound.
|
|
(rx/timeout 1000 (rx/throw (ex-info "no failure state within the bound" {})))
|
|
(rx/subs!
|
|
(fn [state]
|
|
(t/is (contains? (:dashboard-fetch-failures state) ::dd/fetch-recent-files)
|
|
"the placeholder resolves into a failure state"))
|
|
(fn [cause]
|
|
(t/is false (str "unexpected error: " cause))
|
|
(done'))
|
|
(fn []
|
|
(done')))))
|
|
done))))
|
|
|
|
(t/deftest never-answering-project-files-fetch-resolves-placeholder-to-failure
|
|
(t/async done
|
|
(let [project-id (uuid/next)
|
|
state {}]
|
|
(mock/with-mocks
|
|
{rp/fetch-timeout-ms 50
|
|
rp/cmd! (mock/stub (fn [_ _] (rx/subject)))}
|
|
(fn [done']
|
|
(->> (ptk/watch (dpj/fetch-files project-id) state nil)
|
|
(rx/reduce (fn [state event] (ptk/update event state)) state)
|
|
(rx/timeout 1000 (rx/throw (ex-info "no failure state within the bound" {})))
|
|
(rx/subs!
|
|
(fn [state]
|
|
(t/is (contains? (:dashboard-fetch-failures state) ::dpj/fetch-files)
|
|
"the placeholder resolves into a failure state"))
|
|
(fn [cause]
|
|
(t/is false (str "unexpected error: " cause))
|
|
(done'))
|
|
(fn []
|
|
(done')))))
|
|
done))))
|
|
|
|
(t/deftest websocket-reconnect-refetches-placeholder-data
|
|
(t/async done
|
|
(let [team-id (uuid/next)
|
|
project-id (uuid/next)
|
|
calls (atom [])
|
|
state {:current-team-id team-id
|
|
:route {:params {:query {:project-id (str project-id)}}}}
|
|
store (ptk/store {:state state
|
|
:on-error (fn [cause] (t/is false (str cause)))})]
|
|
(mock/with-mocks
|
|
{rp/cmd! (mock/stub (fn [cmd params]
|
|
(swap! calls conj [cmd params])
|
|
(rx/of nil)))}
|
|
(fn [done']
|
|
(ptk/emit! store (dd/initialize team-id))
|
|
(ptk/emit! store (ptk/data-event ::dws/opened {}))
|
|
(t/is (= 2 (count (filter #(= :get-projects (first %)) @calls)))
|
|
"the reconnect refetches the team projects exactly once")
|
|
(t/is (= 1 (count (filter #(= :get-team-recent-files (first %)) @calls)))
|
|
"the reconnect refetches the recent files exactly once")
|
|
(t/is (= 1 (count (filter #(= :get-project-files (first %)) @calls)))
|
|
"the reconnect refetches the project files exactly once")
|
|
(t/is (= [{:project-id project-id}]
|
|
(into [] (comp (filter #(= :get-project-files (first %)))
|
|
(map second))
|
|
@calls))
|
|
"the refetched project files are the ones the placeholder waits for")
|
|
(rx/dispose! store)
|
|
(done'))
|
|
done))))
|