penpot/frontend/test/frontend_tests/data/persistence_commits_test.cljs
alonso.torres 4f42ab1d2d ✨ Keep the valid commits of a save that fails validation
A save whose changes failed validation was rejected as a whole: autosave
stopped, the edits made after it piled up unsent, and a reload lost
them all, the valid ones included.

The frontend now sends each commit of a save as its own group with its
commit id (:changes-with-metadata). When applying and validating all of
them at once fails, the backend searches by bisection for the first
commit that fails, keeps the commits before it and answers with
:rejected (that commit and every later one). Only the kept changes are
stored, snapshotted and broadcast, so other users never see the rejected
ones. Flat :changes and groups without ids stay all-or-nothing; database
and network errors still fail the whole request. A rejection without an
error type is reported as an internal error.

The frontend rolls the file back to what the server holds: it undoes
every pending commit of that file, applies again the remote changes that
arrived meanwhile, and redoes the kept commits. Undoing only the
rejected commits would write stale values over a peer's later change to
the same attribute. The rejected and later commits leave the queue, the
buffer and the undo history, and a warning that hides after ten seconds
tells the user. The pure bookkeeping lives in
app.main.data.persistence.commits.

The pending notice of a commit the rollback dropped no longer leaves the
save status at pending, which made "Pin this version" hang and fail.

Debug tools drive these cases by hand: debug.commitInvalidChange() makes a
save fail validation, holdSaves() delays the answers, failNextSave() and
failNextRollback() fail them on purpose (backend flag debug-save-faults),
and persistenceState() shows the queue.
2026-11-05 23:59:32 +01:00

109 lines
4.5 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.persistence-commits-test
"Tests for the pure commit bookkeeping of persistence
(`app.main.data.persistence.commits`)."
(:require
[app.common.uuid :as uuid]
[app.main.data.persistence.commits :as pcm]
[cljs.test :as t :include-macros true]))
(defn- commit
[file-id undo-group]
{:id (uuid/next)
:file-id file-id
:file-revn 0
:file-vern 0
:features #{}
:source :local
:undo-group undo-group
:redo-changes [{:type :mod-page :id (uuid/next) :name "redo"}]
:undo-changes [{:type :mod-page :id (uuid/next) :name "undo"}]
:remote-seq 0})
(defn- queued
"Persistence state with `entries` queued, in order."
[& entries]
{:queue (into #queue [] (map :id) entries)
:index (into {} (map (juxt :id identity)) entries)})
(t/deftest a-queue-entry-keeps-only-what-a-save-needs
(let [file-id (uuid/next)
c1 (commit file-id (uuid/next))
c2 (commit file-id (uuid/next))
entry (pcm/queue-entry [c1 c2])]
(t/is (= (:id c1) (:id entry)))
(t/is (= file-id (:file-id entry)))
(t/is (not (contains? entry :redo-changes)))
(t/is (= [(:id c1) (:id c2)] (mapv :id (:commits entry))))
(t/is (not (contains? (first (:commits entry)) :source)))))
(t/deftest pending-commits-are-the-queued-then-the-buffered-of-one-file
(let [file-id (uuid/next)
other-id (uuid/next)
c1 (commit file-id nil)
c2 (commit other-id nil)
c3 (commit file-id nil)
pstate (assoc (queued (pcm/queue-entry [c1]) (pcm/queue-entry [c2]))
:buffer [c3])]
(t/is (= [(:id c1) (:id c3)] (mapv :id (pcm/pending-commits pstate file-id))))))
(t/deftest a-rollback-plan-reverts-whole-undo-groups-across-files
(let [file-id (uuid/next)
lib-id (uuid/next)
group (uuid/next)
kept (commit file-id (uuid/next))
bad (commit file-id (uuid/next))
lib-1 (commit lib-id (uuid/next))
lib-2 (commit lib-id (uuid/next))
linked (commit lib-id group)
later (commit file-id group)
saved (assoc (commit lib-id group) :file-id lib-id)
head (pcm/queue-entry [kept bad])
pstate (-> (queued head (pcm/queue-entry [lib-1 linked lib-2]))
(assoc :buffer [later]
:saved [saved]))
plan (pcm/rollback-plan pstate head {:commit-ids [(:id bad)]})]
(t/testing "this file reverts from the rejected commit on"
(t/is (= [(:id bad) (:id later)]
(mapv :id (get-in plan [:files file-id :reverted])))))
(t/testing "the library reverts from the commit that shares a group on"
(t/is (= [(:id linked) (:id lib-2)]
(mapv :id (get-in plan [:files lib-id :reverted]))))
(t/is (= [(:id lib-1)] (mapv :id (get-in plan [:files lib-id :redo])))))
(t/testing "saved commits of a reverted group are compensated"
(t/is (= [(:id saved)] (mapv :id (:compensate plan)))))
(t/is (= {:reverted 5 :files 2} (pcm/rollback-summary plan)))
(t/testing "the rolled back commits leave the queue and the buffer"
(let [pstate' (pcm/without-rolled-back pstate head plan)]
(t/is (= [(:id kept)] (mapv :id (get-in pstate' [:index (:id head) :commits]))))
(t/is (= [(:id lib-1)]
(mapv :id (get-in pstate' [:index (:id lib-1) :commits]))))
(t/is (empty? (:buffer pstate')))
(t/is (empty? (:saved pstate')))))))
(t/deftest an-entry-left-without-commits-leaves-the-queue
(let [file-id (uuid/next)
lib-id (uuid/next)
group (uuid/next)
bad (commit file-id group)
linked (commit lib-id group)
head (pcm/queue-entry [bad])
lib (pcm/queue-entry [linked])
pstate (queued head lib)
plan (pcm/rollback-plan pstate head {:commit-ids [(:id bad)]})
pstate' (pcm/without-rolled-back pstate head plan)]
(t/is (= [(:id head)] (vec (:queue pstate'))) "the in-flight entry stays until it is discarded")
(t/is (not (contains? (:index pstate') (:id lib))))))
(t/deftest compensation-undoes-newest-first
(let [c1 {:redo-changes [:r1] :undo-changes [:u1]}
c2 {:redo-changes [:r2] :undo-changes [:u2]}]
(t/is (= {:redo-changes [:u2 :u1] :undo-changes [:r1 :r2]}
(pcm/compensation-changes [c1 c2])))))