Alonso Torres f89879cca1
🐛 Fix plugin component lookups for missing and deleted components (#12755)
* 🐛 Fix plugin component lookups for missing and deleted components

Shape.component() threw "Assert failed: (uuid? id)" on a copy whose
library is not available, because the head lookup returns a pair with a
nil component and the proxy was built anyway. It now returns null.

Shape.componentRoot() built its proxy with the component's file and main
page, so a copy on another page or file than its main got a shape that
pointed at nothing, and a copy of a missing library threw. It now
resolves the root on the shape's own file and page, like componentHead().

LibraryComponent.mainInstance() returned a proxy for the root kept in a
deleted component's objects, which is on no page, so every field read
empty and parent threw. It now returns null for a deleted component.

* 📚 Fix the variant example in the MCP instructions

The createVariantContainer example called penpot.currentPage.findAllShapes
and Shape.isMainComponent, which do not exist, so running it as written
failed. Use penpotUtils.findShapes with isComponentMainInstance instead.
2026-11-11 09:39:04 +01:00

630 lines
30 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.plugins.library-test
(:require
[app.common.files.changes-builder :as pcb]
[app.common.logic.shapes :as cls]
[app.common.test-helpers.components :as cthc]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as cths]
[app.common.types.component :as ctk]
[app.common.uuid :as uuid]
[app.main.data.event :as ev]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.variants :as dwv]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.worker :as mw]
[app.plugins.library :as library]
[app.plugins.register :as r]
[app.plugins.shape :as shape]
[app.plugins.text :as text]
[app.plugins.utils :as u]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]
[frontend-tests.helpers.async :as async]
[frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.state :as ths]
[potok.v2.core :as ptk]))
(def ^:private plugin-id "00000000-0000-0000-0000-000000000000")
(defn- file-with-component
[]
(-> (cthf/sample-file :restore-file :page-label :main-page)
(cths/add-sample-shape :main :type :frame)
(cths/add-sample-shape :child :parent-label :main)
(cthc/make-component :restorable :main)
(cthc/instantiate-component :restorable :copy)))
(defn- file-with-deleted-component
[]
(let [file (file-with-component)
page (cthf/current-page file)
changes (-> (pcb/empty-changes nil (:id page))
(cls/generate-delete-shapes (:data file) page (:objects page) #{(thi/id :main)} {})
second)]
(cthf/apply-changes file changes)))
(t/deftest ^:async component-restore-preserves-the-users-view
(doseq [scenario [:same-page :other-page]]
(let [file (cond-> (file-with-deleted-component)
(= scenario :other-page) (cthf/add-sample-page :other-page))
store (ths/setup-store file {:renderer :svg})
view {:selected #{(thi/id :copy)}
:zoom 2
:vport {:x 100 :y 200 :width 800 :height 600}
:vbox {:x 50 :y 100 :width 400 :height 300}}
view-of #(select-keys % [:current-file-id :current-page-id :workspace-local])]
(ptk/emit! store #(assoc % :workspace-local view))
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
(let [before (view-of @store)]
(await
(mock/with-mocks*
{st/state store
st/emit! (mock/stub (fn [& events] (apply ptk/emit! store events)))
mw/ask! (mock/stub (fn [_] (rx/empty)))
shape/lib-component-proxy library/lib-component-proxy}
(let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy))
component (.component copy)]
(t/is (true? (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted])))
(.restore component)
(await (async/wait-for #(not (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted]))
"component restored"))
(.restore component)
(await (async/settle))
(t/is (= (thi/id :main)
(get-in @store [:files (:id file) :data :pages-index (thi/id :main-page) :objects (thi/id :main) :id])))
(t/is (= (thi/id :child)
(get-in @store [:files (:id file) :data :pages-index (thi/id :main-page) :objects (thi/id :child) :id])))
(t/is (= before (view-of @store)))
(t/is (= [(str (thi/id :restorable))]
(mapv #(.-id %) (.-components (library/library-proxy plugin-id (:id file)))))))))))))
(t/deftest ^:async component-restore-checks-library-write-permission
(let [file (file-with-deleted-component)
store (ths/setup-store file {:renderer :svg})
events (atom [])]
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
(await
(mock/with-mocks*
{st/state store
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
r/check-permission (mock/stub (constantly false))}
(let [component (library/lib-component-proxy plugin-id (:id file) (thi/id :restorable))]
(t/is (thrown-with-msg? js/Error #"library:write" (.restore component)))
(await (async/settle))
(t/is (empty? @events))
(t/is (true? (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted]))))))))
(t/deftest ^:async component-restore-rejects-linked-libraries
(let [library (file-with-deleted-component)
file (cthf/sample-file :consumer-file)
store (ths/setup-store file {:renderer :svg :libraries [library]})
before @store
events (atom [])]
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
(await
(mock/with-mocks*
{st/state store
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
(let [component (library/lib-component-proxy plugin-id (:id library) (thi/id :restorable))]
(t/is (thrown-with-msg? js/Error #"current file" (.restore component)))
(await (async/settle))
(t/is (empty? @events))
(t/is (= (:files before) (:files @store))))))))
(t/deftest ^:async component-restore-rejects-a-missing-component
(let [file (cthf/sample-file :restore-file)
store (ths/setup-store file {:renderer :svg})]
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
(await
(mock/with-mocks*
{st/state store}
(let [component (library/lib-component-proxy plugin-id (:id file) (uuid/next))]
(t/is (thrown-with-msg? js/Error #"Component does not exist" (.restore component))))))))
(def ^:private component-mutations
[["remove" #(.remove %)]
["duplicate" #(.duplicate %)]
["annotation" #(set! (.-annotation %) "Usage")]])
(t/deftest ^:async component-mutations-check-library-write-permission
(let [file (file-with-component)
store (ths/setup-store file {:renderer :svg})
before @store
events (atom [])]
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
(await
(mock/with-mocks*
{st/state store
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
r/check-permission (mock/stub (constantly false))}
(let [component (library/lib-component-proxy plugin-id (:id file) (thi/id :restorable))]
(doseq [[operation mutate] component-mutations]
(t/testing operation
(t/is (thrown-with-msg? js/Error #"library:write" (mutate component)))))
(await (async/settle))
(t/is (empty? @events))
(t/is (= (:files before) (:files @store))))))))
(t/deftest ^:async component-mutations-reject-linked-libraries
(let [library (file-with-component)
file (cthf/sample-file :consumer-file)
store (ths/setup-store file {:renderer :svg :libraries [library]})
before @store
events (atom [])]
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
(await
(mock/with-mocks*
{st/state store
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
(let [component (library/lib-component-proxy plugin-id (:id library) (thi/id :restorable))]
(doseq [[operation mutate] component-mutations]
(t/testing operation
(t/is (thrown-with-msg? js/Error #"current file" (mutate component)))))
(await (async/settle))
(t/is (empty? @events))
(t/is (= (:files before) (:files @store))))))))
(t/deftest ^:async component-mutations-reject-missing-components
(let [file (cthf/sample-file :restore-file)
store (ths/setup-store file {:renderer :svg})]
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
(await
(mock/with-mocks*
{st/state store}
(let [component (library/lib-component-proxy plugin-id (:id file) (uuid/next))]
(doseq [[operation mutate] component-mutations]
(t/testing operation
(t/is (thrown-with-msg? js/Error #"Component does not exist" (mutate component))))))))))
(t/deftest ^:async update-main-checks-content-and-library-write-permissions
(doseq [denied ["content:write" "library:write"]]
(let [file (file-with-component)
store (ths/setup-store file {:renderer :svg})
events (atom [])]
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
(await
(mock/with-mocks*
{st/state store
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
r/check-permission (mock/stub (fn [_ permission] (not= permission denied)))}
(let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy))]
(t/is (thrown-with-msg? js/Error (re-pattern denied) (.updateMain copy)))
(await (async/settle))
(t/is (empty? @events))))))))
(t/deftest ^:async main-instance-of-a-deleted-component-is-null
(let [file (file-with-deleted-component)
store (ths/setup-store file {:renderer :svg})]
(await
(mock/with-mocks*
{st/state store
shape/lib-component-proxy library/lib-component-proxy}
(let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy))
component (.component copy)]
(t/is (some? component))
(t/is (nil? (.mainInstance component))))))))
(t/deftest ^:async component-root-of-a-copy-on-another-page-than-its-main
(let [file (-> (cthf/sample-file :file :page-label :main-page)
(cths/add-sample-shape :main :type :frame)
(cths/add-sample-shape :child :parent-label :main)
(cthc/make-component :component :main)
(cthf/add-sample-page :other-page)
(cthc/instantiate-component :component :copy :children-labels [:copy-child]))
store (ths/setup-store file {:renderer :svg})]
(await
(mock/with-mocks*
{st/state store}
(let [copy-child (shape/shape-proxy plugin-id (:id file) (thi/id :other-page) (thi/id :copy-child))
root (.componentRoot copy-child)]
(t/is (= (str (thi/id :copy)) (.-id root)))
(t/is (= (str (thi/id :copy)) (.-id (.componentHead copy-child)))))))))
(t/deftest ^:async component-of-a-copy-whose-library-is-missing-is-null
(let [file (-> (file-with-component)
(cths/update-shape :copy :component-file (uuid/next)))
store (ths/setup-store file {:renderer :svg})]
(await
(mock/with-mocks*
{st/state store
shape/lib-component-proxy library/lib-component-proxy}
(let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy))]
(t/is (nil? (.component copy)))
(t/is (= (str (thi/id :copy)) (.-id (.componentRoot copy)))))))))
(t/deftest library-asset-proxies-expose-library-id
(let [file-id (random-uuid)
id (random-uuid)]
(t/is (= (str file-id) (.-libraryId (library/lib-color-proxy plugin-id file-id id))))
(t/is (= (str file-id) (.-libraryId (library/lib-typography-proxy plugin-id file-id id))))
(t/is (= (str file-id) (.-libraryId (library/lib-component-proxy plugin-id file-id id))))))
(t/deftest typography-apply-to-text-range-uses-hidden-range-bounds
(let [file-id (random-uuid)
page-id (random-uuid)
shape-id (random-uuid)
typography-id (random-uuid)
typography (library/lib-typography-proxy plugin-id file-id typography-id)
text-range (text/text-range-proxy plugin-id file-id page-id shape-id 2 5)
captured (atom nil)]
(with-redefs [r/check-permission (constantly true)
u/page-active? (constantly true)
u/locate-library-typography
(constantly {:id typography-id
:name "Body"
:font-size "14"})
dwt/update-text-range
(fn [shape-id start end attrs]
(reset! captured {:shape-id shape-id
:start start
:end end
:attrs attrs})
:update-text-range)
st/emit! mock/noop]
(.applyToTextRange typography text-range)
(t/is (= shape-id (:shape-id @captured)))
(t/is (= 2 (:start @captured)))
(t/is (= 5 (:end @captured)))
(t/is (= file-id (get-in @captured [:attrs :typography-ref-file])))
(t/is (= typography-id (get-in @captured [:attrs :typography-ref-id]))))))
(t/deftest library-color-gradient-and-image-clear-exclusive-representations
(let [file-id (random-uuid)
color-id (random-uuid)
proxy (library/lib-color-proxy plugin-id file-id color-id)
captured (atom nil)
base {:id color-id
:name "Brand"
:color "#fabada"
:opacity 1
:gradient {:type :linear}
:image {:id (random-uuid) :width 1 :height 1}}]
(with-redefs [r/check-permission (constantly true)
u/proxy->library-color (constantly base)
dwl/update-color-data (fn [color file-id]
(reset! captured {:color color :file-id file-id})
:update-color-data)
st/emit! mock/noop]
(set! (.-gradient proxy)
#js {:type "linear"
:startX 0
:startY 0
:endX 1
:endY 1
:width 1
:stops #js [#js {:color "#000000"
:opacity 1
:offset 0}]})
(t/is (contains? (:color @captured) :gradient))
(t/is (not (contains? (:color @captured) :color)))
(t/is (not (contains? (:color @captured) :image)))
(set! (.-image proxy)
#js {:id (str (random-uuid))
:width 10
:height 20
:mtype "image/png"})
(t/is (contains? (:color @captured) :image))
(t/is (not (contains? (:color @captured) :color)))
(t/is (not (contains? (:color @captured) :gradient))))))
;; ---------------------------------------------------------------------------
;; Permission checks (T9-F-02)
;; ---------------------------------------------------------------------------
(t/deftest variant-add-variant-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop]
(let [proxy (library/variant-proxy plugin-id file-id id)]
(.addVariant proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest variant-add-property-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
st/emit! mock/noop]
(let [proxy (library/variant-proxy plugin-id file-id id)]
(.addProperty proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addProperty "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest variant-remove-property-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}])
st/emit! mock/noop]
(let [proxy (library/variant-proxy plugin-id file-id id)]
(.removeProperty proxy 0)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :removeProperty "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest variant-rename-property-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}])
st/emit! mock/noop]
(let [proxy (library/variant-proxy plugin-id file-id id)]
(.renameProperty proxy 0 "newName")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :renameProperty "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest component-transform-in-variant-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
u/locate-library-component (constantly {:id id :main-instance-id id})
ctk/is-variant? (constantly false)
st/emit! mock/noop]
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
(.transformInVariant proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest component-add-variant-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
u/locate-library-component (constantly {:id id :main-instance-id id})
ctk/is-variant? (constantly true)
st/emit! mock/noop]
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
(.addVariant proxy)
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest component-set-variant-property-checks-permission
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
errors (atom [])]
(with-redefs [r/check-permission (constantly false)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
u/locate-library-component (constantly {:id id :variant-properties [{:name "color"}]})
st/emit! mock/noop]
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
(.setVariantProperty proxy 0 "red")
(t/is (= 1 (count @errors)))
(t/is (= [plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission"]
(first @errors)))))))
(t/deftest variant-rename-property-sanitizes-name
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
renamed (atom nil)
errors (atom [])]
(with-redefs [r/check-permission (constantly true)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}])
dwv/update-property-name (fn [vid pos name opts]
(reset! renamed {:id vid :pos pos :name name :opts opts})
::rename-event)
st/emit! mock/noop]
(let [proxy (library/variant-proxy plugin-id file-id id)]
(.renameProperty proxy 0 "a=b")
(t/is (empty? @errors))
(t/is (= "ab" (:name @renamed)))
(.renameProperty proxy 0 "=,=")
(t/is (= 1 (count @errors)))
(t/is (= :name (second (first @errors))))))))
(t/deftest component-set-variant-property-sanitizes-value
(let [plugin-id "test-plugin"
file-id (uuid/next)
id (uuid/next)
updated (atom nil)
errors (atom [])]
(with-redefs [r/check-permission (constantly true)
u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg])))
u/locate-library-component (constantly {:id id :variant-properties [{:name "color"}]})
dwv/update-property-value (fn [cid pos value]
(reset! updated {:id cid :pos pos :value value})
::value-event)
st/emit! mock/noop]
(let [proxy (library/lib-component-proxy plugin-id file-id id)]
(.setVariantProperty proxy 0 "a=b")
(t/is (empty? @errors))
(t/is (= "ab" (:value @updated)))
(.setVariantProperty proxy 0 "=,=")
(t/is (empty? @errors))
(t/is (= "" (:value @updated)))))))
;; ---------------------------------------------------------------------------
;; connectLibrary
;; ---------------------------------------------------------------------------
(defn- shared-files-cmd
"Answers every RPC command asynchronously with `shared-files`, recording
the calls in `mock/rpc-calls`."
[shared-files]
(mock/stub
(fn [cmd params]
(swap! mock/rpc-calls conj {:cmd cmd :params params})
(rx/observe-on :async (rx/of shared-files)))))
(defn- settled
"Resolves with `{:resolved value}` or `{:rejected cause}` once `p` settles,
or with `{:timeout true}` when it stays pending for 2 seconds."
[p]
(js/Promise.race
#js [(.then p (fn [v] {:resolved v}) (fn [e] {:rejected e}))
(js/Promise. (fn [resolve] (js/setTimeout #(resolve {:timeout true}) 2000)))]))
(t/deftest ^:async connect-library-links-a-published-library
(let [file-id (uuid/next)
team-id (uuid/next)
library-id (uuid/next)
stream (rx/subject)
events (atom [])
state (atom {:current-file-id file-id :current-team-id team-id
:permissions {:can-edit true}})]
(await
(mock/with-mocks*
{st/state state
st/stream stream
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
rp/cmd! (shared-files-cmd [{:id library-id :name "Published"}])
r/check-permission (mock/stub (constantly true))}
(let [context (library/library-subcontext plugin-id)
promise (.connectLibrary context (str library-id))
result (settled promise)
done? (atom false)
link? #(= ::dwl/link-file-to-library (ptk/type %))]
(.then promise #(reset! done? true))
(await (async/wait-for #(some link? @events) "link event emitted"))
(rx/push! stream (ptk/data-event ::dwl/link-file-to-library-finished))
(await (async/settle))
(t/is (false? @done?) "waits until the library data is loaded")
(swap! state assoc-in [:files library-id :data] {:id library-id})
(let [{:keys [resolved]} (await result)
link-data (ev/-data (first (filter link? @events)))]
(t/is (= library-id (obj/get resolved "$id")))
(t/is (= file-id (:file-id link-data)))
(t/is (= library-id (:library-id link-data)))
(t/is (= [{:cmd :get-team-shared-files :params {:team-id team-id}}]
@mock/rpc-calls))))))))
(t/deftest ^:async connect-library-rejects-an-unpublished-file
(let [file-id (uuid/next)
library-id (uuid/next)
events (atom [])]
(await
(mock/with-mocks*
{st/state (atom {:current-file-id file-id :current-team-id (uuid/next)
:permissions {:can-edit true}})
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
rp/cmd! (shared-files-cmd [{:id (uuid/next) :name "Other published"}])
r/check-permission (mock/stub (constantly true))}
(let [context (library/library-subcontext plugin-id)
{:keys [rejected]} (await (settled (.connectLibrary context (str library-id))))]
(t/is (str/includes? (str rejected) "is not a published library"))
(t/is (empty? @events)))))))
(t/deftest ^:async connect-library-rejects-the-current-file
(let [file-id (uuid/next)
events (atom [])]
(await
(mock/with-mocks*
{st/state (atom {:current-file-id file-id :current-team-id (uuid/next)
:permissions {:can-edit true}})
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
rp/cmd! (shared-files-cmd [{:id file-id :name "Current file"}])
r/check-permission (mock/stub (constantly true))}
(let [context (library/library-subcontext plugin-id)
{:keys [rejected]} (await (settled (.connectLibrary context (str file-id))))]
(t/is (str/includes? (str rejected) "is not a published library"))
(t/is (empty? @events)))))))
(t/deftest ^:async connect-library-rejects-an-invalid-id
(await
(mock/with-mocks*
{st/state (atom {:current-file-id (uuid/next) :current-team-id (uuid/next)})
st/emit! mock/noop
rp/cmd! (shared-files-cmd [])
r/check-permission (mock/stub (constantly true))}
(let [context (library/library-subcontext plugin-id)
{:keys [rejected]} (await (settled (.connectLibrary context "not-a-uuid")))]
(t/is (str/includes? (str rejected) "not-a-uuid"))
(t/is (empty? @mock/rpc-calls))))))
(t/deftest ^:async connect-library-checks-permission
(await
(mock/with-mocks*
{st/state (atom {:current-file-id (uuid/next) :current-team-id (uuid/next)})
st/emit! mock/noop
rp/cmd! (shared-files-cmd [])
r/check-permission (mock/stub (constantly false))}
(let [context (library/library-subcontext plugin-id)
{:keys [rejected]} (await (settled (.connectLibrary context (str (uuid/next)))))]
(t/is (str/includes? (str rejected) "Plugin doesn't have 'library:write' permission"))
(t/is (empty? @mock/rpc-calls))))))
(t/deftest ^:async library-context-ignores-files-left-by-earlier-workspaces
(let [file-id (uuid/next)
linked-id (uuid/next)
visited-id (uuid/next)
other-id (uuid/next)]
(await
(mock/with-mocks*
{st/state (atom {:current-file-id file-id
:current-team-id (uuid/next)
:files {file-id {:id file-id :data {}}
linked-id {:id linked-id :data {}}
visited-id {:id visited-id}}})
rp/cmd! (shared-files-cmd [{:id linked-id :name "Linked"}
{:id visited-id :name "Visited"}
{:id other-id :name "Other"}])
r/check-permission (mock/stub (constantly true))}
(let [context (library/library-subcontext plugin-id)]
(t/is (= #{file-id linked-id}
(into #{} (map #(obj/get % "$id")) (.-connected context))))
(t/is (= #{(str visited-id) (str other-id)}
(into #{} (map #(.-id %)) (await (.availableLibraries context))))))))))
;; Scenario: a library is linked; while the list of published libraries
;; loads, a save fails and locks the workspace. Proves: the library is not
;; linked and the promise rejects with the read-only error.
(t/deftest ^:async connect-library-is-refused-when-a-save-fails-meanwhile
(let [library-id (uuid/next)
state (atom {:current-file-id (uuid/next)
:current-team-id (uuid/next)
:permissions {:can-edit true}})
answer (rx/subject)
events (atom [])]
(await
(mock/with-mocks*
{st/state state
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
rp/cmd! (mock/stub (fn [cmd params]
(swap! mock/rpc-calls conj {:cmd cmd :params params})
(->> answer (rx/take 1) (rx/observe-on :async))))}
(let [context (library/library-subcontext plugin-id)
result (settled (.connectLibrary context (str library-id)))]
(await (async/wait-for #(seq @mock/rpc-calls) "the published libraries are requested"))
(swap! state assoc-in [:workspace-global :persistence-lock] {:read-only? false})
(rx/push! answer [{:id library-id :name "Published"}])
(let [{:keys [rejected]} (await result)]
(t/is (str/includes? (ex-message rejected) "cannot be saved"))
(t/is (empty? @events) "the library is not linked")))))))