penpot/frontend/test/frontend_tests/plugins/management_test.cljs
alonso.torres a381761aa4 ✨ Add project and file creation to penpotMgmt
Add penpotMgmt.createProject({name, teamId?}) and
penpotMgmt.createFile({projectId, name}). Both require content:write,
trim the name and require 1 to 250 characters, and return the same
metadata as listProjects and listFiles without changing the current
view. When the new project or file belongs to the current team, the
dashboard state is updated so it shows up right away.

listProjects, listFiles and the creation methods share the summary
builders, so fileCount is now 0 instead of missing for new projects.

Update the plugin types, the MCP API docs and the MCP instructions.
2026-10-07 14:58:32 +02:00

281 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.plugins.management-test
(:require
[app.common.uuid :as uuid]
[app.main.repo :as rp]
[app.main.store :as st]
[app.plugins.events :as events]
[app.plugins.management :as management]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as a]
[frontend-tests.helpers.mock :as mock]))
(def file-id (uuid/next))
(def page-id (uuid/next))
(def team-id (uuid/next))
(def dashboard
{:route {:data {:name :dashboard-recent}}
:current-team-id team-id})
(def loading
(assoc dashboard
:route {:data {:name :workspace}
:params {:query {:file-id (str file-id) :team-id (str team-id)}}}
:current-file-id file-id
:workspace-ready file-id))
(def ready
(assoc loading
:current-page-id page-id
:files {file-id {:id file-id :name "Design"
:data {:pages-index {page-id {:id page-id :objects {}}}}}}))
(t/deftest workspace-readiness-requires-an-initialized-page
(t/is (= "none" (:status (management/workspace-context dashboard))))
(t/is (= "loading" (:status (management/workspace-context loading))))
(t/is (= "ready" (:status (management/workspace-context ready))))
(t/is (= "none" (:status (management/workspace-context (assoc ready :route (:route dashboard)))))))
(t/deftest ^:async open-file-waits-for-page-readiness
(let [state (atom dashboard)
seen (atom [])
done? (atom false)]
(await
(mock/with-mocks*
{st/state state
st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0))
([_ & _] nil))}
(let [listener (events/add-listener "workspacechange" (str uuid/zero)
#(swap! seen conj (.-status %)) nil)
result (-> (management/open-file (str file-id) nil)
(.then #(reset! done? true)))]
(try
(await (a/wait-for #(= ["loading"] @seen)))
(t/is (false? @done?))
(reset! state ready)
(await result)
(t/is (= ["loading" "ready"] @seen))
(t/is (true? @done?))
(finally
(events/remove-listener listener))))))))
(t/deftest ^:async open-file-rejects-superseded-navigation
(let [state (atom dashboard)]
(await
(mock/with-mocks*
{st/state state
st/emit! (fn ([_] (js/setTimeout #(reset! state loading) 0))
([_ & _] nil))}
(let [result (-> (management/open-file (str file-id) nil)
(.then (fn [] nil) (fn [error] (.-message error))))]
(await (a/wait-for #(= loading @state)))
(reset! state {:route {:data {:name :settings-profile}}})
(t/is (= "File navigation was superseded" (await result))))))))
(t/deftest ^:async open-file-rejects-invalid-identifiers
(let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)})
(.then (fn [] nil) (fn [error] (.-message error))))]
(t/is (= "Expected a file UUID and a team UUID" (await result)))))
(t/deftest ^:async list-projects-returns-active-metadata
(let [project-id (uuid/next)
other-team-id (uuid/next)]
(doseq [[state options expected-team-id]
[[dashboard nil team-id]
[ready #js {:teamId (str other-team-id)} other-team-id]]]
(let [state* (atom state)]
(await
(mock/with-mocks*
{st/state state*
rp/cmd! (mock/stub
(fn [command params]
(t/is (= [:get-projects {:team-id expected-team-id}]
[command params]))
(rx/of [{:id project-id :team-id expected-team-id
:name "Drafts" :is-default true :count 2 :total-count 3}
{:id (uuid/next) :deleted-at (js/Date.)}])))}
(let [projects (await (management/list-projects options))]
(t/is (= [{"id" (str project-id) "teamId" (str expected-team-id)
"name" "Drafts" "isDefault" true "fileCount" 2}]
(js->clj projects)))
(t/is (= state @state*)))))))))
(t/deftest ^:async list-files-returns-metadata-without-opening-a-file
(let [project-id (uuid/next)]
(doseq [state [dashboard ready]]
(let [state* (atom state)]
(await
(mock/with-mocks*
{st/state state*
rp/cmd! (mock/stub
(fn [command params]
(t/is (= [:get-project-files {:project-id project-id}]
[command params]))
(rx/of [{:id file-id :team-id team-id :project-id project-id
:name "Design" :modified-at (js/Date. "2026-01-02T03:04:05Z")
:revn 10}])))}
(let [files (await (management/list-files #js {:projectId (str project-id)}))]
(t/is (= [{"id" (str file-id) "teamId" (str team-id)
"projectId" (str project-id) "name" "Design"
"modifiedAt" "2026-01-02T03:04:05.000Z"}]
(js->clj files)))
(t/is (= state @state*)))))))))
(t/deftest ^:async listing-rejects-invalid-identifiers-without-querying
(await
(mock/with-mocks*
{st/state (atom {})
rp/cmd! (mock/stub (fn [_ _] (t/is false "Invalid IDs must not query the backend")))}
(doseq [[result message]
[[(management/list-projects nil) "Expected a team UUID"]
[(management/list-projects #js {:teamId "invalid"}) "Expected a team UUID"]
[(management/list-files nil) "Expected a project UUID"]
[(management/list-files #js {:projectId "invalid"}) "Expected a project UUID"]]]
(t/is (= message (await (.then result (fn [] nil) #(.-message %)))))))))
(t/deftest ^:async listing-preserves-empty-results-and-backend-errors
(doseq [list-items [(fn [] (management/list-projects #js {:teamId (str team-id)}))
(fn [] (management/list-files #js {:projectId (str (uuid/next))}))]]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [_ _] (rx/of [])))}
(t/is (= [] (js->clj (await (list-items)))))))
(let [error (js/Error. "Access denied")]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))}
(t/is (identical? error (await (.then (list-items) (fn [] nil) identity)))))))))
(t/deftest ^:async create-project-returns-metadata-and-updates-only-the-current-team
(let [project-id (uuid/next)
other-team-id (uuid/next)]
(doseq [[initial options target-team-id]
[[dashboard #js {:name " Buttons "} team-id]
[ready #js {:name " Buttons " :teamId (str other-team-id)} other-team-id]]]
(let [state (atom initial)
requests (atom [])
project {:id project-id :team-id target-team-id :name "Buttons"
:is-default false :is-pinned false}]
(await
(mock/with-mocks*
{st/state state
st/emit! (mock/stub (fn [event] (swap! state event)))
rp/cmd! (mock/stub
(fn [command params]
(swap! requests conj [command params])
(->> (rx/of project) (rx/observe-on :async))))}
(let [context (management/create-context)
result (await (.createProject context options))]
(t/is (= {"id" (str project-id) "teamId" (str target-team-id)
"name" "Buttons" "isDefault" false "fileCount" 0}
(js->clj result)))
(t/is (= [[:create-project {:team-id target-team-id :name "Buttons"}]] @requests))
(t/is (= (if (= team-id target-team-id)
(assoc-in initial [:projects project-id] (assoc project :count 0))
initial)
@state)))))))))
(t/deftest ^:async create-file-returns-metadata-and-updates-the-dashboard-without-navigation
(let [project-id (uuid/next)
created-id (uuid/next)
modified-at (js/Date. "2026-01-02T03:04:05Z")]
(doseq [initial [dashboard ready]]
(let [initial (assoc initial
:features #{"components/v2" "styles/v2" "plugins/runtime"}
:projects {project-id {:id project-id :team-id team-id :count 2}})
state (atom initial)
requests (atom [])
file {:id created-id :team-id team-id :project-id project-id
:name "Login" :modified-at modified-at :data {:pages []}}]
(await
(mock/with-mocks*
{st/state state
st/emit! (mock/stub (fn [event] (swap! state event)))
rp/cmd! (mock/stub
(fn [command params]
(swap! requests conj [command params])
(->> (rx/of file) (rx/observe-on :async))))}
(let [context (management/create-context)
result (await (.createFile context #js {:projectId (str project-id) :name " Login "
:id "ignored" :isShared true}))]
(t/is (= {"id" (str created-id) "teamId" (str team-id)
"projectId" (str project-id) "name" "Login"
"modifiedAt" "2026-01-02T03:04:05.000Z"}
(js->clj result)))
(t/is (= [[:create-file {:project-id project-id :name "Login" :features #{"components/v2"}}]]
@requests))
(t/is (= (-> initial
(assoc-in [:files created-id] (dissoc file :data))
(assoc-in [:recent-files created-id] (dissoc file :data))
(update-in [:projects project-id :count] inc))
@state)))))))))
(t/deftest ^:async creation-rejects-invalid-input-before-querying
(let [project-id (uuid/next)
requests (atom [])]
(await
(mock/with-mocks*
{st/state (atom dashboard)
rp/cmd! (mock/stub (fn [command params]
(swap! requests conj [command params])
(->> (rx/of nil) (rx/observe-on :async))))}
(let [context (management/create-context)]
(doseq [options [nil #js {} #js {:name nil} #js {:name 1}
#js {:name ""} #js {:name " \n "}
#js {:name (apply str (repeat 251 "a"))}]]
(t/is (= "Expected a name with 1 to 250 characters"
(await (.then (.createProject context options) (fn [] nil) #(.-message %)))))
(let [options (js/Object.assign #js {:projectId (str project-id)} options)]
(t/is (= "Expected a name with 1 to 250 characters"
(await (.then (.createFile context options) (fn [] nil) #(.-message %)))))))
(doseq [[create options message]
[[#(.createProject context %) #js {:name "Buttons" :teamId "invalid"} "Expected a team UUID"]
[#(.createFile context %) #js {:name "Login"} "Expected a project UUID"]
[#(.createFile context %) #js {:name "Login" :projectId "invalid"} "Expected a project UUID"]]]
(t/is (= message (await (.then (create options) (fn [] nil) #(.-message %)))))))
(await (a/settle))
(t/is (empty? @requests))))))
(t/deftest ^:async creation-preserves-backend-errors-and-state
(doseq [method ["createProject" "createFile"]]
(let [state (atom dashboard)
error (js/Error. "Access denied")]
(await
(mock/with-mocks*
{st/state state
st/emit! (mock/stub (fn [event] (swap! state event)))
rp/cmd! (mock/stub (fn [_ _] (->> (rx/throw error) (rx/observe-on :async))))}
(let [context (management/create-context)
options #js {:name "Design" :projectId (str (uuid/next))}
result (.call (aget context method) context options)]
(t/is (identical? error (await (.then result (fn [] nil) identity))))
(t/is (= dashboard @state))))))))
(t/deftest ^:async creation-does-not-add-results-to-a-different-team-after-navigation
(doseq [method ["createProject" "createFile"]]
(let [state (atom dashboard)
response (rx/subject)
other (assoc dashboard :current-team-id (uuid/next))
item-id (uuid/next)
item {:id item-id :team-id team-id :project-id (uuid/next)
:name "Design" :modified-at (js/Date.)}]
(await
(mock/with-mocks*
{st/state state
st/emit! (mock/stub (fn [event] (swap! state event)))
rp/cmd! (mock/stub (fn [_ _] (->> response (rx/observe-on :async))))}
(let [context (management/create-context)
result (.call (aget context method) context
#js {:name "Design" :projectId (str (:project-id item))})]
(reset! state other)
(rx/push! response item)
(t/is (= (str item-id) (.-id (await result))))
(t/is (= other @state))))))))