mirror of
https://github.com/penpot/penpot.git
synced 2026-07-02 04:15:26 +00:00
* ✨ Batch multiple thumbnail deletions into a single RPC call Replace the old per-object immediate thumbnail deletion with a debounced batched approach. The frontend queues object-ids in state and waits 200ms before sending a single RPC request with up to 200 object-ids. The backend deletes all matching thumbnails in one SQL statement with a single RETURNING clause, then touches the affected media objects. This reduces RPC overhead when rapidly clearing thumbnails (e.g. navigating pages) and makes deletions more efficient. Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📎 Fix missing issues --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz>
299 lines
14 KiB
Clojure
299 lines
14 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 INC Sucursal en España SL
|
|
|
|
(ns frontend-tests.data.workspace-thumbnails-test
|
|
(:require
|
|
[app.common.thumbnails :as thc]
|
|
[app.common.uuid :as uuid]
|
|
[app.main.data.workspace.thumbnails :as thumbnails]
|
|
[beicon.v2.core :as rx]
|
|
[cljs.test :as t :include-macros true]
|
|
[frontend-tests.helpers.mock :as mock]
|
|
[potok.v2.core :as ptk]))
|
|
|
|
;; The qualified keyword used internally by app.main.data.workspace.thumbnails
|
|
;; for tracking the pending deletion queue in application state.
|
|
(def ^:private deletion-queue-key
|
|
:app.main.data.workspace.thumbnails/thumbnails-deletion-queue)
|
|
|
|
(t/deftest extract-frame-changes-handles-cyclic-frame-links
|
|
(let [page-id (uuid/next)
|
|
root-id (uuid/next)
|
|
shape-a-id (uuid/next)
|
|
shape-b-id (uuid/next)
|
|
event {:changes [{:type :mod-obj
|
|
:page-id page-id
|
|
:id shape-a-id}]}
|
|
old-data {:pages-index
|
|
{page-id
|
|
{:objects
|
|
{root-id {:id root-id :type :frame :frame-id uuid/zero}
|
|
shape-a-id {:id shape-a-id :type :rect :frame-id shape-b-id}
|
|
shape-b-id {:id shape-b-id :type :group :frame-id shape-a-id}}}}}
|
|
new-data {:pages-index
|
|
{page-id
|
|
{:objects
|
|
{root-id {:id root-id :type :frame :frame-id uuid/zero}
|
|
shape-a-id {:id shape-a-id :type :rect :frame-id root-id}
|
|
shape-b-id {:id shape-b-id :type :group :frame-id shape-a-id
|
|
:component-root true}}}}}]
|
|
(t/is (= #{["frame" root-id]
|
|
["component" shape-b-id]}
|
|
(#'thumbnails/extract-frame-changes page-id [event [old-data new-data]]))))
|
|
|
|
;; --- Batch deletion queue state management ---
|
|
|
|
(t/deftest clear-thumbnail-adds-to-deletion-queue
|
|
(let [file-id (uuid/next)
|
|
object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
uri "blob:http://localhost/test-thumb"
|
|
event (thumbnails/clear-thumbnail file-id object-id)
|
|
state {:thumbnails {object-id uri}}
|
|
result (ptk/update event state)]
|
|
;; Thumbnail removed from the map
|
|
(t/is (nil? (get-in result [:thumbnails object-id])))
|
|
;; Object-id added to the deletion queue with its URI
|
|
(t/is (= uri (get-in result [deletion-queue-key object-id])))))
|
|
|
|
(t/deftest clear-thumbnail-keeps-other-thumbnails
|
|
(let [file-id (uuid/next)
|
|
object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
uri1 "blob:http://localhost/thumb-1"
|
|
uri2 "blob:http://localhost/thumb-2"
|
|
event (thumbnails/clear-thumbnail file-id object-id1)
|
|
state {:thumbnails {object-id1 uri1 object-id2 uri2}}
|
|
result (ptk/update event state)]
|
|
;; Only the cleared thumbnail is removed
|
|
(t/is (nil? (get-in result [:thumbnails object-id1])))
|
|
(t/is (= uri2 (get-in result [:thumbnails object-id2])))
|
|
;; Only the cleared thumbnail is queued
|
|
(t/is (= uri1 (get-in result [deletion-queue-key object-id1])))
|
|
(t/is (nil? (get-in result [deletion-queue-key object-id2])))))
|
|
|
|
(t/deftest clear-thumbnail-accumulates-in-queue
|
|
(let [file-id (uuid/next)
|
|
object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
uri1 "blob:http://localhost/thumb-1"
|
|
uri2 "blob:http://localhost/thumb-2"
|
|
event1 (thumbnails/clear-thumbnail file-id object-id1)
|
|
event2 (thumbnails/clear-thumbnail file-id object-id2)
|
|
state {:thumbnails {object-id1 uri1 object-id2 uri2}}
|
|
state1 (ptk/update event1 state)
|
|
state2 (ptk/update event2 state1)]
|
|
;; Both removed from thumbnails
|
|
(t/is (nil? (get-in state2 [:thumbnails object-id1])))
|
|
(t/is (nil? (get-in state2 [:thumbnails object-id2])))
|
|
;; Both accumulated in the queue
|
|
(t/is (= uri1 (get-in state2 [deletion-queue-key object-id1])))
|
|
(t/is (= uri2 (get-in state2 [deletion-queue-key object-id2])))
|
|
(t/is (= 2 (count (get state2 deletion-queue-key))))))
|
|
|
|
(t/deftest remove-from-deletion-queue-removes-entry
|
|
(let [file-id (uuid/next)
|
|
object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
event (thumbnails/remove-from-deletion-queue object-id)
|
|
state {deletion-queue-key {object-id "blob:http://localhost/thumb"}}
|
|
result (ptk/update event state)]
|
|
(t/is (nil? (get-in result [deletion-queue-key object-id])))
|
|
(t/is (empty? (get result deletion-queue-key)))))
|
|
|
|
(t/deftest remove-from-deletion-queue-keeps-other-entries
|
|
(let [file-id (uuid/next)
|
|
object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
uri1 "blob:http://localhost/thumb-1"
|
|
uri2 "blob:http://localhost/thumb-2"
|
|
event (thumbnails/remove-from-deletion-queue object-id1)
|
|
state {deletion-queue-key {object-id1 uri1
|
|
object-id2 uri2}}
|
|
result (ptk/update event state)]
|
|
;; Only the specified entry is removed
|
|
(t/is (nil? (get-in result [deletion-queue-key object-id1])))
|
|
(t/is (= uri2 (get-in result [deletion-queue-key object-id2])))
|
|
(t/is (= 1 (count (get result deletion-queue-key))))))
|
|
|
|
(t/deftest remove-before-clear-cancels-pending-delete
|
|
(let [file-id (uuid/next)
|
|
object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
uri "blob:http://localhost/thumb"
|
|
;; Step 1: clear-thumbnail queues the delete
|
|
state1 (ptk/update (thumbnails/clear-thumbnail file-id object-id)
|
|
{:thumbnails {object-id uri}})
|
|
;; Step 2: remove-from-deletion-queue cancels the pending delete
|
|
state2 (ptk/update (thumbnails/remove-from-deletion-queue object-id)
|
|
state1)]
|
|
;; Thumbnail was removed from :thumbnails map by clear-thumbnail
|
|
(t/is (nil? (get-in state2 [:thumbnails object-id])))
|
|
;; But the deletion queue entry was cancelled by remove-from-deletion-queue
|
|
(t/is (nil? (get-in state2 [deletion-queue-key object-id])))
|
|
(t/is (empty? (get state2 deletion-queue-key)))))
|
|
|
|
(t/deftest clear-thumbnail-batch-drains-queue
|
|
(let [file-id (uuid/next)
|
|
object-id1 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
object-id2 (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
uri1 "blob:http://localhost/thumb-1"
|
|
uri2 "blob:http://localhost/thumb-2"
|
|
;; Build up the queue state manually (simulating accumulated clear-thumbnails)
|
|
state {deletion-queue-key {object-id1 uri1 object-id2 uri2}}
|
|
event (#'thumbnails/clear-thumbnail-batch)
|
|
result (ptk/update event state)]
|
|
;; The queue is drained from application state
|
|
(t/is (empty? (get result deletion-queue-key)))))
|
|
|
|
(t/deftest clear-thumbnail-batch-empty-queue-noop
|
|
(let [state {deletion-queue-key {}}
|
|
event (#'thumbnails/clear-thumbnail-batch)
|
|
result (ptk/update event state)]
|
|
;; State unchanged when queue is already empty
|
|
(t/is (empty? (get result deletion-queue-key)))
|
|
(t/is (= state (dissoc result deletion-queue-key)))))
|
|
|
|
(t/deftest assoc-thumbnail-adds-to-map
|
|
(let [object-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame")
|
|
uri "blob:http://localhost/new-thumb"
|
|
event (#'thumbnails/assoc-thumbnail object-id uri)
|
|
state {:thumbnails {}}
|
|
result (ptk/update event state)]
|
|
(t/is (= uri (get-in result [:thumbnails object-id])))))
|
|
|
|
(t/deftest duplicate-thumbnail-copies-entry
|
|
(let [old-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame")
|
|
new-id (thc/fmt-object-id (uuid/next) (uuid/next) (uuid/next) "frame")
|
|
uri "blob:http://localhost/dup-thumb"
|
|
event (thumbnails/duplicate-thumbnail old-id new-id)
|
|
state {:thumbnails {old-id uri}}
|
|
result (ptk/update event state)]
|
|
(t/is (= uri (get-in result [:thumbnails old-id])))
|
|
(t/is (= uri (get-in result [:thumbnails new-id])))))
|
|
|
|
;; --- Async WatchEvent tests ---
|
|
|
|
(defn- make-obj-ids
|
|
"Helper to create n properly-formatted object-ids for a single file."
|
|
[file-id n]
|
|
(vec (repeatedly n #(thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame"))))
|
|
|
|
(t/deftest clear-thumbnail-batch-watch-calls-rpc-with-object-ids
|
|
(t/async done
|
|
(let [file-id (uuid/next)
|
|
oids (make-obj-ids file-id 3)
|
|
state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))}
|
|
event (#'thumbnails/clear-thumbnail-batch)]
|
|
(ptk/update event state)
|
|
(mock/with-mocks
|
|
{#'app.main.repo/cmd! mock/rpc-cmd!-mock
|
|
#'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock}
|
|
(fn [done']
|
|
(->> (ptk/watch event state nil)
|
|
(rx/reduce conj [])
|
|
(rx/subs!
|
|
(fn [_] nil)
|
|
(fn [err] (t/is (nil? err)) (done'))
|
|
(fn [_]
|
|
(t/is (= 1 (count @mock/rpc-calls)))
|
|
(let [[{:keys [cmd params]}] @mock/rpc-calls]
|
|
(t/is (= :delete-file-object-thumbnails cmd))
|
|
(t/is (= (vec oids) (:object-ids params))))
|
|
(done')))))
|
|
done))))
|
|
|
|
(t/deftest clear-thumbnail-batch-watch-partitions-large-batch
|
|
(t/async done
|
|
(let [file-id (uuid/next)
|
|
oids (make-obj-ids file-id 250)
|
|
state {deletion-queue-key (zipmap oids (repeat "blob:http://test"))}
|
|
event (#'thumbnails/clear-thumbnail-batch)]
|
|
(ptk/update event state)
|
|
(mock/with-mocks
|
|
{#'app.main.repo/cmd! mock/rpc-cmd!-mock
|
|
#'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock}
|
|
(fn [done']
|
|
(->> (ptk/watch event state nil)
|
|
(rx/reduce conj [])
|
|
(rx/subs!
|
|
(fn [_] nil)
|
|
(fn [err] (t/is (nil? err)) (done'))
|
|
(fn [_]
|
|
(t/is (= 2 (count @mock/rpc-calls)))
|
|
(let [[c1 c2] @mock/rpc-calls]
|
|
(t/is (= :delete-file-object-thumbnails (:cmd c1)))
|
|
(t/is (= :delete-file-object-thumbnails (:cmd c2)))
|
|
(t/is (= 200 (count (:object-ids (:params c1)))))
|
|
(t/is (= 50 (count (:object-ids (:params c2)))))
|
|
(t/is (= (set oids)
|
|
(set (concat (:object-ids (:params c1))
|
|
(:object-ids (:params c2)))))))
|
|
(done')))))
|
|
done))))
|
|
|
|
(t/deftest clear-thumbnail-batch-watch-revokes-blob-uris
|
|
(t/async done
|
|
(let [file-id (uuid/next)
|
|
oids (make-obj-ids file-id 2)
|
|
uris ["blob:http://localhost/thumb-1"
|
|
"blob:http://localhost/thumb-2"]
|
|
state {deletion-queue-key (zipmap oids uris)}
|
|
event (#'thumbnails/clear-thumbnail-batch)]
|
|
(ptk/update event state)
|
|
(mock/with-mocks
|
|
{#'app.main.repo/cmd! (fn [_ _] (rx/of nil))
|
|
#'app.util.webapi/revoke-uri mock/revoke-uri-mock
|
|
#'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock}
|
|
(fn [done']
|
|
(->> (ptk/watch event state nil)
|
|
(rx/reduce conj [])
|
|
(rx/subs!
|
|
(fn [_] nil)
|
|
(fn [err] (t/is (nil? err)) (done'))
|
|
(fn [_]
|
|
(t/is (= (set uris) (set @mock/revoked-uris)))
|
|
(done')))))
|
|
done))))
|
|
|
|
(t/deftest clear-thumbnail-batch-watch-empty-queue-no-rpc
|
|
(t/async done
|
|
(let [event (#'thumbnails/clear-thumbnail-batch)
|
|
state {}]
|
|
(ptk/update event state)
|
|
(mock/with-mocks
|
|
{#'app.main.repo/cmd! mock/rpc-cmd!-mock
|
|
#'app.util.timers/schedule-on-idle mock/schedule-on-idle-mock}
|
|
(fn [done']
|
|
(->> (ptk/watch event state nil)
|
|
(rx/reduce conj [])
|
|
(rx/subs!
|
|
(fn [_] nil)
|
|
(fn [err] (t/is (nil? err)) (done'))
|
|
(fn [_]
|
|
(t/is (empty? @mock/rpc-calls))
|
|
(done')))))
|
|
done))))
|
|
|
|
(t/deftest clear-thumbnail-watch-emits-batch-after-debounce
|
|
(t/async done
|
|
(let [file-id (uuid/next)
|
|
object-id (thc/fmt-object-id file-id (uuid/next) (uuid/next) "frame")
|
|
uri "blob:http://localhost/thumb"
|
|
state {:thumbnails {object-id uri}}
|
|
event (thumbnails/clear-thumbnail file-id object-id)]
|
|
(ptk/update event state)
|
|
(mock/with-mocks
|
|
{#'beicon.v2.core/timer mock/timer-mock}
|
|
(fn [done']
|
|
(->> (ptk/watch event state nil)
|
|
(rx/reduce conj [])
|
|
(rx/subs!
|
|
(fn [_] nil)
|
|
(fn [err] (t/is (nil? err)) (done'))
|
|
(fn [events]
|
|
(t/is (= 1 (count events)))
|
|
(t/is (ptk/event? (first events)))
|
|
(done')))))
|
|
done)))))
|