penpot/frontend/test/frontend_tests/data/persistence_test.cljs
2026-10-19 20:10:20 +02:00

1139 lines
58 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-test
"Tests for the file persistence save pipeline (`app.main.data.persistence`):
read-only saves, the stalled-save watchdog, the core save pipeline
(queueing, failures, recovery), and the transient/terminal failure
classification. Retry-episode behavior lives in
`frontend-tests.data.persistence-retry-test`.
The watchdog and read-only tests are fully async: `mock/with-mocks*`
installs the doubles, `^:async` bodies `await` observable effects (never
assert straight after a trigger), and completion propagates through
promises — test code threads no `done`. The remaining tests still use
`mock/with-mocks` with an explicit `done` chain."
(:require
[app.common.time :as ct]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
[app.main.data.persistence :as dps]
[app.main.data.render-wasm :as drw]
[app.main.data.workspace :as dw]
[app.main.errors :as errors]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.store :as st]
[app.util.i18n :as i18n]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]
[frontend-tests.helpers.mock :as mock]
[okulary.core :as l]
[potok.v2.core :as ptk]))
(t/deftest reopening-restores-only-the-retained-edits-of-the-loaded-file
(doseq [[status cause-type vern restored?]
[[:retrying nil 0 true]
[:saving nil 0 true]
[:error :offline 0 true]
[:error :validation 0 false]
[:retrying nil 1 false]]]
(t/testing (str status " / " cause-type " / version " vern)
(let [file-id (uuid/next)
page-id (uuid/next)
shape-id (uuid/next)
first-id (uuid/next)
next-id (uuid/next)
other-id (uuid/next)
shape (cts/setup-shape {:id shape-id :type :rect :x 0 :y 0})
file {:id file-id
:vern vern
:data {:pages [page-id]
:pages-index {page-id {:id page-id :name "Page"
:objects {uuid/zero
(cts/setup-shape {:id uuid/zero :type :frame})}}}}}
rename (fn [name]
{:type :mod-obj :id shape-id :page-id page-id
:operations [{:type :set :attr :name :val name}]})
pstate {:status status
:error {:cause-type cause-type :commit-id first-id}
:queue (into #queue [] [first-id next-id other-id])
:index {first-id {:file-id file-id :file-vern 0
:redo-changes [{:type :add-obj :id shape-id :obj shape
:page-id page-id :parent-id uuid/zero
:frame-id uuid/zero}]}
next-id {:file-id file-id :file-vern 0
:redo-changes [(rename "Retained rectangle")]}
other-id {:file-id (uuid/next) :file-vern 0
:redo-changes [(rename "Another file")]}}}
state {:persistence pstate}
loaded (ptk/update (#'dw/bundle-fetched {:file file :file-id file-id :thumbnails {}})
state)
restored (get-in loaded [:files file-id :data :pages-index page-id :objects shape-id])]
(t/is (= restored? (some? restored)))
(when restored?
(t/is (= "Retained rectangle" (:name restored))
"queued edits apply in order and exclude other files"))
(t/is (identical? pstate (:persistence loaded))
"restoring the editor must preserve the save queue")))))
;; Scenario: a queued edit no longer applies to the data the server sent, so
;; replaying it throws. Proves: the file still opens, as the server sent it,
;; and the edits stay queued.
(t/deftest reopening-with-an-edit-that-no-longer-applies-keeps-the-server-file
(let [file-id (uuid/next)
page-id (uuid/next)
commit-id (uuid/next)
data {:pages [page-id]
:pages-index {page-id {:id page-id :name "Page"
:objects {uuid/zero
(cts/setup-shape {:id uuid/zero :type :frame})}}}}
file {:id file-id :vern 0 :data data}
pstate {:status :saving
:queue (into #queue [] [commit-id])
:index {commit-id {:file-id file-id :file-vern 0
:redo-changes [{:type :mod-obj :id uuid/zero :page-id page-id
:operations [{:type :no-longer-supported}]}]}}}
loaded (ptk/update (#'dw/bundle-fetched {:file file :file-id file-id :thumbnails {}})
{:persistence pstate})]
(t/is (identical? data (get-in loaded [:files file-id :data])))
(t/is (identical? pstate (:persistence loaded)))))
(defn- local-commit
"Builds a synthetic local commit event for `file-id`: a page edit as redo,
no undo, from the `:local` source."
[file-id]
(ptk/data-event ::dch/commit
{:id (uuid/next)
:file-id file-id
:file-revn 0
:file-vern 0
:source :local
:features #{}
:redo-changes [{:type :mod-page :id (uuid/next) :name "Edited"}]
:undo-changes []}))
(defn- ^:async run-read-only-phases
"Drives the read-only save scenario presuming asynchronous APIs: every
phase emits its triggers, awaits the transport round-trip, and only
then asserts the observed state."
[store response requests file-id read-only-event errors]
(ptk/emit! store (dps/initialize-persistence)
(local-commit file-id)
read-only-event
::dps/force-persist)
(await (async/await-response requests response {:revn 1}))
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))
(ptk/emit! store (drw/context-restored)
#(assoc-in % [:workspace-global :read-only?] false)
(local-commit file-id)
::dps/force-persist)
(await (async/await-response requests response {:revn 2}))
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))
(t/is (empty? @errors)))
(defn- ^:async check-read-only-save
"Saves a queued edit while the file is read-only and verifies that the edit
is persisted once the restriction is lifted.
Scenario: with persistence initialized, queue an edit, switch the file to
read-only (`read-only-event`), and force persistence — the edit goes out
and is acknowledged. Then restore an editable context, queue another edit
and persist again. Both saves land in order with no reported errors.
Proves: read-only defers writes without dropping them, and resumption
picks up where it left off."
[read-only-event]
(let [file-id (uuid/next)
response (rx/subject)
requests (atom [])
errors (atom [])
store (ptk/store {:state {:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}}
:on-error #(swap! errors conj %)})]
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [cmd params]
(let [req (->> response (rx/take 1) (rx/observe-on :async))]
(swap! requests conj {:cmd cmd :params params :req req})
req)))
st/ongoing-tasks (atom #{})}
(try
(await (run-read-only-phases store response requests file-id read-only-event errors))
(finally
(rx/dispose! store)
(rx/end! response))))))
;; Variant: the file goes read-only through render context loss.
(t/deftest ^:async queued-edits-save-after-context-loss
(await (check-read-only-save (drw/context-lost))))
;; Variant: the file goes read-only through the workspace flag.
(t/deftest ^:async queued-edits-save-in-read-only-mode
(await (check-read-only-save #(assoc-in % [:workspace-global :read-only?] true))))
;; Variant: the file goes read-only through preview mode.
(t/deftest ^:async queued-edits-save-in-read-only-preview-mode
(await (check-read-only-save #(assoc % :workspace-global {:read-only? true
:preview-id (uuid/next)}))))
;; Scenario: the open file is a historical preview (read-only with a preview
;; id). Attempting a commit there must produce nothing observable: the gate
;; answers refusals with an empty stream (never nil), so the test observes
;; termination instead of branching on nil. Previews cannot create local
;; commits. Proves: the read-only preview gate holds at the watch level.
(t/deftest ^:async historical-preview-cannot-create-local-commits
(let [file-id (uuid/next)
output (atom [])
state {:current-file-id file-id
:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0 :vern 0}}
:workspace-global {:read-only? true :preview-id (uuid/next)}}
event (dch/commit-changes {:redo-changes [] :undo-changes []})]
(await (async/observe (ptk/watch event state (rx/empty))
:on-next #(swap! output conj %)))
(t/is (empty? @output))))
;; Watchdog: stalled-save detection.
;;
;; Production contract under test (`app.main.data.persistence`,
;; `saving-stall-timeout-ms` = 5 minutes):
;; - A request unanswered past the deadline reports once as
;; `:saving-stalled` (a `handled-exception` audit event) and never
;; repeats for the same stall (`:stall-reported`).
;; - Any completed save resets the clock: a later stall reports again.
;; - Edits queued behind a stall are preserved; an already failed save is
;; not re-reported as a stall.
;;
;; Emulation model — the fixture freezes the three inputs the watchdog
;; reads, so each test scripts time by hand:
;; - clock (`ct/now`): an atom. The test sets the time; time never flows.
;; - ticks (`rx/interval`): a subject. Each push runs one watchdog pass.
;; - network (`rp/cmd!`): answered when the test pushes into `:response`
;; (scenario timing stays in test hands); delivery is asynchronous, so
;; every phase awaits its effects before asserting them.
;; - reports (`errors/generate-report`/`submit-report`): recorded into
;; atoms for assertions; nothing leaves the test. Thunk reports are
;; evaluated through the `generate-report` double, mirroring production
;; (granted reports build, suppressed ones never exist here).
;;
;; Reading the tests below: every assert block is preceded by quiescence —
;; `wait-for` on its presence-conditions, or a bare `settle` tick when it
;; asserts only absence.
(defn- with-watchdog
"Async fixture for the persistence watchdog tests: mocks the clock, timers
and RPC transport, and runs `f` with persistence initialized.
Evaluates to a promise resolving once `f` settles and teardown completes;
`await` it from an `^:async` test. The transport answers when the test
pushes into `:response` (scenario timing stays in test hands); delivery
is asynchronous, so await each effect — via `wait-for` — before asserting
it. `f` is awaited (usually an `^:async` fn)."
[f]
(let [clock (atom 0)
ticks (rx/subject)
response (rx/subject)
requests (atom [])
reports (atom [])
causes (atom [])
file-id (uuid/next)
store (ptk/store {:state {:current-file-id file-id
:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}}
:on-error #(t/is false (str %))})]
(mock/with-mocks*
{ct/now (mock/stub #(ct/inst @clock))
rx/interval (mock/stub (fn [_] ticks))
rp/cmd! (mock/stub (fn [cmd params]
(swap! requests conj {:cmd cmd :params params})
(->> response (rx/take 1) (rx/observe-on :async))))
st/state store
errors/generate-report (fn [cause & _]
(swap! causes conj cause)
"report")
errors/submit-report (fn [& params]
;; Mirrors production: a granted report builds
;; its payload through `generate-report`, so a
;; thunk report is evaluated here while a
;; string report is recorded as it arrives.
(let [m (apply hash-map params)]
(swap! reports conj m)
(when (fn? (:report m))
((:report m)))))
st/ongoing-tasks (atom #{})}
(try
(ptk/emit! store (dps/initialize-persistence))
(await (f {:clock clock :ticks ticks :response response :requests requests
:causes causes :reports reports :store store :file-id file-id}))
(finally
(rx/dispose! store)
(rx/end! ticks)
(rx/end! response))))))
;; Scenario: persistence sits in a non-terminal status with an empty
;; queue past the deadline (a status transition racing the queue drain).
;; The watchdog must stay silent: with nothing queued there is nothing
;; stalled to report. Proves: no false-positive stall reports on an
;; empty queue.
(t/deftest ^:async empty-queue-never-reports-a-stall
(await
(with-watchdog
(^:async fn [{:keys [clock ticks reports store]}]
(ptk/emit! store (#'dps/update-status :pending))
(reset! clock 300001)
(rx/push! ticks :tick)
(await (async/settle))
(t/is (empty? @reports) "an empty queue is never a stall")))))
;; Scenario: the network hangs. One save goes out and is never answered.
;; Five minutes pass with no report (the deadline is exclusive); one second
;; later, with more edits piled behind the stalled request, the tick reports
;; the stall exactly once — as `handled-exception`, carrying the file id and
;; the lost render context — and later ticks repeat nothing while both edits
;; stay queued. Proves: one report per stall, no lost edits.
(t/deftest ^:async stalled-request-is-reported-once-without-discarding-edits
(await
(with-watchdog
(^:async fn [{:keys [clock ticks reports causes store file-id]}]
;; Phase 1 — at exactly five minutes the deadline has not elapsed: silence.
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(reset! clock 300000)
(rx/push! ticks :tick)
(await (async/settle))
(t/is (empty? @reports) "Five minutes must elapse before reporting")
;; Phase 2 — one second past the deadline the stall reports exactly once.
;; More local edits must not reset the stalled request's clock.
(reset! clock 300001)
(ptk/emit! store (drw/context-lost)
(local-commit file-id) ::dps/force-persist)
(rx/push! ticks :tick)
(await (async/wait-for #(= 1 (count @reports)) "stall report"))
(t/is (= 1 (count @reports)))
(t/is (= "handled-exception" (:event-name (first @reports))))
(let [data (ex-data (first @causes))]
(t/is (= :saving-stalled (:code data)))
(t/is (= file-id (:file-id data)))
(t/is (true? (:render-context-lost? data))))
;; Phase 3 — long after: no repeat, both edits still queued.
(reset! clock 900000)
(rx/push! ticks :tick)
(await (async/settle))
(t/is (= 1 (count @reports)) "Do not repeat a report for the same stall")
(t/is (= :saving (get-in @store [:persistence :status])))
(t/is (= 2 (count (get-in @store [:persistence :queue]))))))))
;; Scenario: answers arrive, then stop. Two commits share the first request;
;; awaiting its landing advances the queue and starts the second request, so
;; the tick finds progress and stays silent. Left hanging past a fresh
;; deadline it reports once; answering it saves the file and silences the
;; watchdog; a later hang reports again. Proves: progress resets the clock,
;; and every new stall earns its own report.
;;
;; Note the awaits: a push only schedules delivery, so each tick must
;; observe the completed save — awaiting after the push is what separates
;; "answered" from "completed".
(t/deftest ^:async successful-saves-reset-the-stall-clock-and-allow-a-new-report
(await
(with-watchdog
(^:async fn [{:keys [clock ticks reports response store file-id]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist
(local-commit file-id) ::dps/force-persist)
;; Phase 1 — two commits share the first request; its landing starts the second.
(reset! clock 290000)
(rx/push! response {:revn 1})
(await (async/wait-for #(= 1 (count (get-in @store [:persistence :queue])))
"first save lands, second request starts"))
(reset! clock 300001)
(rx/push! ticks :tick)
(await (async/settle))
(t/is (empty? @reports) "The queue is making progress")
;; Phase 2 — the second request hangs past its own fresh deadline.
(reset! clock 590001)
(rx/push! ticks :tick)
(await (async/wait-for #(= 1 (count @reports)) "second request stalls"))
(t/is (= 1 (count @reports)) "The second request has now stalled")
(rx/push! response {:revn 2})
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"second save lands"))
(t/is (= :saved (get-in @store [:persistence :status])))
;; Phase 3 — answering saves the file: the tick stays silent.
(reset! clock 1000000)
(rx/push! ticks :tick)
(t/is (= 1 (count @reports)) "A saved file must not be reported")
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
;; Phase 4 — a later hang is a new stall with its own report.
(reset! clock 1300001)
(rx/push! ticks :tick)
(await (async/wait-for #(= 2 (count @reports)) "later stall reports again"))
(t/is (= 2 (count @reports)) "A later stall gets its own report")))))
;; Scenario: no network request exists at all — an edit sits `:pending`
;; locally without being sent. The watchdog still tracks it against the
;; original deadline (re-marking `:pending` does not push it): an idle
;; file never reports, an aged pending edit reports once, and once the
;; save fails outright the watchdog stands down — failures belong to the
;; save path, never twice. Proves: pending edits are watched without
;; extending deadlines, and failures are not re-reported as stalls.
(t/deftest ^:async pending-edits-are-monitored-without-extending-the-deadline
(await
(with-watchdog
(^:async fn [{:keys [clock ticks reports store file-id]}]
;; Phase 1 — idle: nothing queued, nothing reported.
(rx/push! ticks :tick)
(await (async/settle))
(t/is (empty? @reports) "An idle file must not be reported")
;; Phase 2 — a queued (yet unsent) edit ages into one report, and
;; re-marking pending does not extend its deadline.
(let [id (uuid/next)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {id {:id id :file-id file-id}}})))
(ptk/emit! store (#'dps/update-status :pending))
(reset! clock 300001)
(ptk/emit! store (#'dps/update-status :pending))
(rx/push! ticks :tick)
(await (async/wait-for #(= 1 (count @reports)) "pending edit ages into a report"))
(t/is (= 1 (count @reports)))
;; Phase 3 — an outright failure is not a stall: no second report.
(ptk/emit! store (#'dps/update-status :error))
(reset! clock 900000)
(rx/push! ticks :tick)
(await (async/settle))
(t/is (= 1 (count @reports)) "Do not report an already failed save")))))
;; Scenario: initializing twice must replace the watchdog instead of stacking
;; it — a single active timer at all times, zero after teardown. Proves:
;; reinitialization swaps the timer subscription instead of leaking it.
;; (Fully synchronous bodies need no awaits; the promise shell still
;; guarantees restore and completion.)
(t/deftest ^:async reinitializing-persistence-replaces-the-watchdog
(await
(let [active-timers (atom 0)
ticks (rx/subject)
store (ptk/store {:state {} :on-error #(t/is false (str %))})]
(mock/with-mocks*
{rx/interval (mock/stub
(fn [_]
(rx/create
(fn [subscriber]
(swap! active-timers inc)
(let [subscription (.subscribe ticks subscriber)]
(fn []
(rx/dispose! subscription)
(swap! active-timers dec)))))))
st/ongoing-tasks (atom #{})}
(try
(ptk/emit! store (dps/initialize-persistence))
(t/is (= 1 @active-timers))
(ptk/emit! store (dps/initialize-persistence))
(t/is (= 1 @active-timers))
(finally
(rx/dispose! store)
(rx/end! ticks)
(t/is (zero? @active-timers))))))))
;; Scenario: the first send fails transiently and the retry resend hangs
;; forever inside the `:retrying` episode. Past the 5-minute deadline the
;; watchdog must still report the stall exactly once — and never repeat
;; it — while the queued edit is preserved. Proves: the watchdog sees
;; hung resends; a `:retrying` episode cannot deadlock silently.
(t/deftest ^:async hung-retry-resend-is-reported-as-a-stall
(let [calls (atom 0)
timer-s (rx/subject)]
(await
(with-watchdog
(^:async fn [{:keys [clock ticks reports causes requests store file-id]}]
(await
(mock/with-mocks*
{rx/timer (mock/stub (fn [_] timer-s))
rp/cmd! (mock/stub (fn [cmd params]
(swap! requests conj {:cmd cmd :params params})
(if (= 1 (swap! calls inc))
(rx/throw (ex-info "offline" {:type :offline}))
;; The resend hangs forever: the subject
;; is never answered nor failed.
(rx/subject))))}
;; Phase 1 — first send fails transiently; the episode parks
;; on the hand-fired timer.
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(await (async/wait-for #(= :retrying (get-in @store [:persistence :status]))
"transient failure retries"))
(t/is (= 1 (count @requests)))
;; Phase 2 — the timer fires and the resend goes out, hanging.
(rx/push! timer-s :tick)
(await (async/wait-for #(= 2 (count @requests)) "retry resends"))
(t/is (= :retrying (get-in @store [:persistence :status])))
;; Phase 3 — past the deadline the watchdog reports the stall
;; once, keeping the queued edit.
(reset! clock 300001)
(rx/push! ticks :tick)
(await (async/wait-for #(= 1 (count @reports)) "hung resend stalls"))
(t/is (= 1 (count @reports)))
(t/is (= "handled-exception" (:event-name (first @reports))))
(t/is (= :saving-stalled (:code (ex-data (first @causes)))))
(t/is (= :retrying (get-in @store [:persistence :status])))
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
;; Phase 4 — later ticks repeat nothing for the same stall.
(reset! clock 900000)
(rx/push! ticks :tick)
(await (async/settle))
(t/is (= 1 (count @reports)) "Do not repeat a report for the same stall")
(rx/end! timer-s))))))))
;; Save pipeline tests.
;;
;; Production contract under test (`app.main.data.persistence`):
;; - Commits queue locally and flush in order; every request carries the
;; queued changes exactly once (no resends of active requests, no drops).
;; - Without edit permission nothing is sent and the failure flashes.
;; - Transport failures and malformed answers (`nil`, empty, bad revision)
;; fail the save but preserve the queue for retry.
;; - Initialization recovers gracefully: dangling runners error instead of
;; skipping, unsent commits are picked up, unknown outcomes are sent again
;; under the same commit id.
;;
;; Same async pattern as the watchdog tests: triggers stay bare, every
;; assert block is preceded by `wait-for` on its leading signal (or a bare
;; `settle` tick when it asserts only absence).
(defn- with-persistence
"Async fixture for the persistence tests: mocks the transport, flash and
the leave-page task set (`:tasks`), and runs `f` with persistence
initialized.
Evaluates to a promise resolving once `f` settles and teardown completes;
`await` it from an `^:async` test.
By default the transport responds through the `:response` subject. Pass a
`respond` function (`(fn [cmd params] observable)`) to drive the transport
response directly instead. Delivery through the subject is asynchronous
(`observe-on :async`): await each effect — via `wait-for` — before
asserting it."
[f & [respond]]
(let [file-id (uuid/next)
response (rx/subject)
failures (atom [])
requests (atom [])
tasks (atom #{})
store (ptk/store {:state {:current-file-id file-id
:permissions {:can-edit true}
:files {file-id {:id file-id :revn 0}}}
:on-error #(t/is false (str %))})]
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [cmd params]
(swap! requests conj [cmd params])
(if respond
(respond cmd params)
(->> response (rx/take 1) (rx/observe-on :async)))))
errors/flash (fn [& {:keys [cause]}]
(swap! failures conj cause))
st/ongoing-tasks tasks}
(try
(ptk/emit! store (dps/initialize-persistence))
(await (f {:file-id file-id :response response :failures failures
:requests requests :store store :tasks tasks}))
(finally
(rx/dispose! store)
(rx/end! response))))))
;; Scenario: edit permission is revoked with an edit queued. The save fails
;; without sending anything, the queue keeps the edit and the user is
;; notified once, so `wait-persisted-or-error` still rejects and nothing is
;; snapshotted from unsaved state. Proves: permission loss fails safe.
(t/deftest ^:async permission-loss-retains-the-queue-and-notifies-once
(await
(with-persistence
(^:async fn [{:keys [file-id requests failures store]}]
(ptk/emit! store (local-commit file-id)
#(assoc-in % [:permissions :can-edit] false)
::dps/force-persist)
(await (async/wait-for #(and (= :error (get-in @store [:persistence :status]))
(= 1 (count (get-in @store [:persistence :queue]))))
"permission loss fails the save"))
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= 1 (count (get-in @store [:persistence :queue])))
"the undeliverable edit is retained, so waiters keep rejecting")
(t/is (empty? @requests))
(t/is (= 1 (count @failures)))))))
;; Scenario: the same terminal failure, then the user opens a file again.
;; Opening a file releases the edit that can never be saved and settles the
;; editor, instead of leaving it halted until a reload. Proves: the halt does
;; not outlive the visit that caused it.
(t/deftest ^:async opening-a-file-again-releases-a-terminal-failure
(await
(with-persistence
(^:async fn [{:keys [file-id requests failures store]}]
(ptk/emit! store (local-commit file-id)
#(assoc-in % [:permissions :can-edit] false)
::dps/force-persist)
(await (async/wait-for #(and (= :error (get-in @store [:persistence :status]))
(= 1 (count (get-in @store [:persistence :queue]))))
"the save fails terminally"))
;; The user acts again: a team-role change is one of the two real
;; recovery triggers (the other is opening a file, whose event does
;; real work and is not usable as a bare signal here).
(ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role))
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"re-entering the file settles the queue"))
(t/is (nil? (get-in @store [:persistence :error]))
"the halt is cleared once the user comes back")
(t/is (empty? @requests)
"the edit that cannot be saved is not replayed on re-entry")
(t/is (= 1 (count @failures)) "the user is still notified exactly once")))))
;; Scenario: two files have pending edits and the first send fails
;; terminally. Opening a file again releases the failed file, the other file
;; still saves, and the editor settles saved instead of staying halted.
;; Proves: one file that cannot be saved does not block every other file.
(t/deftest ^:async terminal-failure-does-not-block-other-files
(let [files (into [] (repeatedly 2 uuid/next))
sends (atom 0)]
(await
(with-persistence
(^:async fn [{:keys [requests store]}]
(ptk/emit! store
#(assoc % :files (into (:files %)
(map (fn [id] [id {:id id :revn 0}]))
files))
(local-commit (nth files 0))
(local-commit (nth files 1))
::dps/force-persist)
(await (async/wait-for #(seq @requests) "the first file is sent"))
(let [failed-file (get-in (second (first @requests)) [:id])]
(await (async/wait-for #(and (= :error (get-in @store [:persistence :status]))
(not (empty? (get-in @store [:persistence :queue]))))
"the terminal failure retains the queue"))
;; The user acts again: recovery releases the failed file and drains
;; the rest.
(ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role))
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"the released file frees the rest of the queue"))
(t/is (= 2 (count @requests))
"the other file was saved after the failed one was released")
(t/is (= 1 (count (filter #(= failed-file (get-in (second %) [:id])) @requests)))
"the failed file is sent once, never replayed")))
(fn [_ _]
;; Fail the first send terminally and answer the rest inline, so no
;; hot-subject race can swallow the answer.
(if (= 1 (swap! sends inc))
(rx/throw (ex-info "rejected" {:type :validation :code :invalid-data}))
(rx/of {:revn 1})))))))
(defn- check-save-failure-warning
"Fails a queued save with `cause-type` and asserts the hint shown to the
user; authentication failures have their own UI and must stay silent."
[cause-type]
(with-persistence
(^:async fn [{:keys [file-id response store]}]
(let [notifications (atom [])]
(await
(mock/with-mocks*
{errors/flash (fn [& params]
(swap! notifications conj (apply hash-map params)))
i18n/tr (mock/stub #(str "translated:" %))}
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(.error response (ex-info "Raw transport details" {:type cause-type}))
(await (async/wait-for #(= :error (get-in @store [:persistence :status]))
"failed request errors the save"))
(t/is (= :error (get-in @store [:persistence :status])))
(let [data (get-in @store [:persistence :error])]
(ptk/handle-error (assoc data ::errors/instance (ex-info "Save failed" data))))
(if (= cause-type :authentication)
(do (await (async/settle))
(t/is (empty? @notifications)))
(do (await (async/wait-for #(seq @notifications) "save-failure toast"))
(t/is (= ["translated:errors.save-failed"]
(mapv :hint @notifications)))))))))))
;; Variant: an authentication failure stays silent (own UI flow).
(t/deftest ^:async authentication-save-failure-shows-no-warning
(await (check-save-failure-warning :authentication)))
;; NOTE: the `:network` and `:offline` warning variants lived here while
;; transport failures went straight to `:error`. Transient failures now
;; retry instead, so their coverage moved to the retry tests below (and to
;; the exhaustion test for the terminal toast).
;; The transient/terminal classification is pure: transport failures and
;; unusable save responses retry with backoff, everything else stays
;; terminal. Note the wrapped shape: `persistence-failed` records the
;; original cause under `:cause-type` with `:type :persistence`.
(t/deftest transient-error-classification
(t/is (dps/transient-error? {:type :network}))
(t/is (dps/transient-error? {:type :offline}))
(t/is (dps/transient-error? {:type :bad-gateway}))
(t/is (dps/transient-error? {:type :service-unavailable}))
(t/is (dps/transient-error? {:type :persistence :cause-type :network}))
(t/is (dps/transient-error? {:type :persistence :cause-type :offline}))
(t/is (dps/transient-error? {:type :internal :cause-type :bad-gateway}))
(t/is (dps/transient-error? {:type :persistence :cause-type :service-unavailable}))
;; The wrapped shape stays transient with an explicit non-retry code too:
;; the cause-type carries the transport verdict, not the wrapper's code.
(t/is (dps/transient-error? {:type :persistence :cause-type :service-unavailable :code :save-failed}))
(t/is (dps/transient-error? {:type :persistence :cause-type :bad-gateway :code :save-failed}))
(t/is (dps/transient-error? {:type :persistence :code :invalid-save-response}))
(t/is (not (dps/transient-error? {:type :authentication})))
(t/is (not (dps/transient-error? {:type :validation})))
(t/is (not (dps/transient-error? {:type :internal})))
(t/is (not (dps/transient-error? {:type :persistence :cause-type :authentication})))
(t/is (not (dps/transient-error? {:type :persistence :code :missing-commit})))
(t/is (not (dps/transient-error? {:type :persistence :code :save-permission-denied})))
(t/is (not (dps/transient-error? {}))))
;; A busy backend refused or rolled back the save, so it is safe to resend:
;; a rate or concurrency limit, or a database timeout. Any other server
;; error stays terminal.
(t/deftest busy-server-classification
(t/is (dps/transient-error? {:type :rate-limit}))
(t/is (dps/transient-error? {:type :persistence :cause-type :rate-limit :code :rate-limited}))
(t/is (dps/transient-error? {:type :server-error :code :statement-timeout}))
(t/is (dps/transient-error? {:type :persistence :cause-type :server-error :code :statement-timeout}))
(t/is (dps/transient-error? {:type :persistence :cause-type :server-error :code :idle-in-transaction-timeout}))
(t/is (not (dps/transient-error? {:type :server-error})))
(t/is (not (dps/transient-error? {:type :persistence :cause-type :server-error :code :unhandled})))
(t/is (not (dps/transient-error? {:type :persistence :cause-type :validation :code :statement-timeout}))))
;; The transient set is owned by `repo/retryable-types`: every transport
;; type retryable at the HTTP layer must also be transient for the save
;; pipeline, in both the direct and the wrapped (`persistence-failed`)
;; positions. Proves: the two classifications cannot drift apart.
(t/deftest transient-classification-follows-repo-retryable-types
(doseq [transport-type rp/retryable-types]
(t/testing (str "transport type " transport-type " is transient in both positions")
(t/is (dps/transient-error? {:type transport-type}))
(t/is (dps/transient-error? {:type :persistence :cause-type transport-type})))))
;; Scenario: a save fails transiently and the episode parks on its backoff
;; timer. A waiter started mid-episode must not resolve on the `:retrying`
;; transition — only the terminal save releases it. Proves: `:retrying` is
;; non-terminal for waiters.
(t/deftest waiter-release-rule
;; The waiter release rule is pure: only a failed save or a settled
;; (`nil` / `:saved`) empty queue releases waiters. In particular, the
;; `:retrying` episode — queue non-empty, status non-terminal — never
;; does. Proves: `:retrying` is non-terminal for waiters, a document of
;; intent over the `wait-persisted-or-error` filter.
;;
;; Note: this intentionally avoids scripting the global store atom — it
;; is shared with the whole suite, so any test reading it observes
;; other namespaces' leftovers depending on run order.
(t/is (dps/terminal-status? {:status :error :queue [1]}))
(t/is (dps/terminal-status? {:status :saved :queue []}))
(t/is (dps/terminal-status? {:status nil :queue []}))
(t/is (dps/terminal-status? {:status nil :queue nil}))
(t/is (not (dps/terminal-status? {:status :retrying :queue [1]})))
(t/is (not (dps/terminal-status? {:status :saving :queue [1]})))
(t/is (not (dps/terminal-status? {:status :pending :queue [1]})))
(t/is (not (dps/terminal-status? {:status :saved :queue [1]}))))
;; Scenario: the queue references a commit id with no matching commit
;; (plus a dangling run id) when persistence initializes. It must error
;; instead of silently skipping the unknown entry, keeping it queued and
;; sending nothing. Proves: dangling queue entries fail loudly, never skip.
(t/deftest ^:async missing-commit-is-an-error-instead-of-skipping-changes
(await
(with-persistence
(^:async fn [{:keys [requests store]}]
(let [id (uuid/next)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {} :run-id (uuid/next)
:status :saving})
(dps/initialize-persistence))
(await (async/wait-for #(= :error (get-in @store [:persistence :status]))
"dangling commit errors instead of skipping"))
(t/is (= :error (get-in @store [:persistence :status])))
(t/is (= [id] (vec (get-in @store [:persistence :queue]))))
(t/is (empty? @requests)))))))
;; Scenario: a valid but unsent commit sits queued with a dangling run id
;; when persistence initializes. It is sent exactly once and, once answered,
;; the file saves with an empty queue. Proves: unsent commits survive a
;; stale runner — neither stuck nor duplicated.
(t/deftest ^:async initialization-recovers-an-unsent-commit-with-a-dangling-run-id
(await
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(let [commit (assoc @(local-commit file-id) :changes [])
id (:id commit)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {id commit} :run-id (uuid/next)
:status :saving})
(dps/initialize-persistence))
(await (async/wait-for #(= 1 (count @requests)) "dangling commit is sent"))
(t/is (= 1 (count @requests)))
(rx/push! response {:revn 1})
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"recovered save lands"))
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue]))))))))
(defn- check-active-request-not-resent
"Sends a commit, interrupts the active request with `interrupt` and
reinitializes persistence: the pending request must not be sent again."
[interrupt]
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(apply ptk/emit! store (local-commit file-id) ::dps/force-persist interrupt)
(ptk/emit! store (dps/initialize-persistence))
(await (async/wait-for #(= 1 (count @requests)) "only the active request is sent"))
(t/is (= 1 (count @requests)))
(rx/push! response {:revn 1})
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"active request completes"))
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue]))))))
;; Variant: the interrupt is a reinitialization.
(t/deftest ^:async reinitializing-does-not-resend-an-active-request
(await (check-active-request-not-resent [(dps/initialize-persistence)])))
;; Variant: the interrupt is a save error event.
(t/deftest ^:async save-error-does-not-resend-an-active-request
(await (check-active-request-not-resent [(ptk/data-event ::dps/error)])))
;; Scenario: permission is lost mid-flight, so the save fails terminally.
;; Even if the permission comes back, recovery releases the undeliverable
;; edits instead of replaying them. The backend raises `:not-found` for a
;; lost edit permission, so a resend would only route the user to the
;; exception page. Proves: permission loss is treated as permanent, like
;; every other terminal failure.
(t/deftest ^:async permission-loss-is-released-not-resumed
(await
(with-persistence
(^:async fn [{:keys [file-id requests store]}]
(ptk/emit! store (local-commit file-id)
#(assoc-in % [:permissions :can-edit] false)
::dps/force-persist)
(await (async/wait-for #(and (= :error (get-in @store [:persistence :status]))
(= 1 (count (get-in @store [:persistence :queue]))))
"permission loss fails the save"))
;; Permission comes back and a file is opened again.
(ptk/emit! store #(assoc-in % [:permissions :can-edit] true)
(ptk/data-event :app.main.data.common/change-team-role))
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"recovery releases the undeliverable edit"))
(t/is (empty? @requests)
"the undeliverable edit is never replayed, not even once the
permission is back")
(t/is (not= :error (get-in @store [:persistence :status]))
"the editor is not left halted")))))
;; Scenario: a terminal failure is waiting, and persistence is re-initialized.
;; Initialization must not lift the halt: only opening a file does. Otherwise
;; the edits would be released the moment the workspace view mounts, instead
;; of when the user acts on them. Proves: the init/recovery split holds.
(t/deftest ^:async initialization-does-not-lift-a-halt
(await
(with-persistence
(^:async fn [{:keys [file-id response store]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(.error response (ex-info "Validation failed" {:type :validation}))
(await (async/wait-for #(and (= :error (get-in @store [:persistence :status]))
(= 1 (count (get-in @store [:persistence :queue]))))
"the save fails terminally"))
(ptk/emit! store (dps/initialize-persistence))
(await (async/settle))
(t/is (= :error (get-in @store [:persistence :status]))
"initialization leaves the halt alone")
(t/is (= 1 (count (get-in @store [:persistence :queue])))
"and keeps the undeliverable edits queued until the user acts")))))
;; Scenario: the answer arrives but no runner is tracking the request
;; anymore. The acknowledged commit stays queued instead of being dropped;
;; on initialization it is settled as saved without resending. Proves: late
;; acknowledgments are neither lost nor replayed.
(t/deftest ^:async recovery-keeps-an-acknowledgment-received-without-a-runner
(await
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist
(ptk/data-event ::dps/error))
(rx/push! response {:revn 1})
(await (async/wait-for #(= 1 (count (get-in @store [:persistence :queue])))
"acknowledged commit stays queued"))
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
(ptk/emit! store (dps/initialize-persistence))
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"recovery completes without resending"))
(t/is (= 1 (count @requests)) "The acknowledged changes must not be sent again")
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue])))))))
;; Scenario: a queued commit carries a request id but persistence has no
;; run id for it when initializing, so its outcome is unknown. It is sent
;; again under the same commit id and saves. Proves: an unknown outcome is
;; resent, since changes are idempotent.
(t/deftest ^:async recovery-sends-again-a-save-with-an-unknown-outcome
(await
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(let [commit (assoc @(local-commit file-id) ::dps/request-id (uuid/next))
id (:id commit)]
(ptk/emit! store
#(assoc % :persistence {:queue (conj #queue [] id)
:index {id commit} :status :saving})
(dps/initialize-persistence))
(await (async/wait-for #(= 1 (count @requests)) "the commit is sent again"))
(t/is (= id (:commit-id (second (first @requests)))))
(rx/push! response {:revn 1})
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"the resent commit saves"))
(t/is (= 1 (count @requests))))))))
;; Scenario: an edit is queued before persistence even initializes. On
;; initialization plus force-persist it is sent exactly once (still queued
;; until answered); once answered, the file saves. Proves: pre-init buffered
;; edits flush exactly once, without duplicating.
(t/deftest ^:async initialization-flushes-buffered-edits-without-duplicating-them
(await
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(ptk/emit! store (local-commit file-id)
(dps/initialize-persistence)
::dps/force-persist)
(await (async/wait-for #(and (= 1 (count @requests))
(= 1 (count (get-in @store [:persistence :queue]))))
"buffered edit is sent once"))
(t/is (= 1 (count @requests)))
(t/is (= 1 (count (get-in @store [:persistence :queue]))))
(rx/push! response {:revn 1})
(await (async/wait-for #(= :saved (get-in @store [:persistence :status]))
"buffered edit saves"))
(t/is (= :saved (get-in @store [:persistence :status])))))))
;; Scenario: the transport answers synchronously (immediate observable).
;; The save completes, the queue empties, and no runner is left dangling.
;; Proves: synchronous transport responses settle cleanly.
(t/deftest ^:async synchronous-save-results-do-not-leave-a-dangling-runner
(await
(with-persistence
(^:async fn [{:keys [file-id store]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(await (async/wait-for #(and (= :saved (get-in @store [:persistence :status]))
(empty? (get-in @store [:persistence :queue])))
"synchronous save completes"))
(t/is (= :saved (get-in @store [:persistence :status])))
(t/is (empty? (get-in @store [:persistence :queue]))))
(fn [_ _] (rx/of {:revn 1})))))
;; Leave-page guard tests.
;; Scenario: an edit is buffered, then sent, then saved. The leave-page
;; guard turns on with the buffered edit, stays on while the save is in
;; flight, and turns off once it lands. Proves: unsaved edits always keep
;; the browser's leave-page warning on.
(t/deftest ^:async unsaved-edits-keep-the-leave-page-guard
(await
(with-persistence
(^:async fn [{:keys [file-id response requests store tasks]}]
(ptk/emit! store (local-commit file-id))
(await (async/wait-for #(contains? @tasks :persistence)
"a buffered edit turns the guard on"))
(ptk/emit! store ::dps/force-persist)
(await (async/wait-for #(seq @requests) "the edit is sent"))
(t/is (contains? @tasks :persistence) "the guard stays on while the save is in flight")
(rx/push! response {:revn 1})
(await (async/wait-for #(not (contains? @tasks :persistence))
"the save turns the guard off"))
(t/is (= :saved (get-in @store [:persistence :status])))))))
;; Scenario: a second edit arrives while the first is being saved, and the
;; first save lands while the second edit is still in the buffer. The queue
;; is empty at that point, yet the guard stays on until the second edit is
;; saved. Proves: edits waiting in the buffer keep the leave-page warning on.
(t/deftest ^:async edits-buffered-during-a-save-keep-the-leave-page-guard
(await
(with-persistence
(^:async fn [{:keys [file-id response requests store tasks]}]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(await (async/wait-for #(= 1 (count @requests)) "the first edit is sent"))
(ptk/emit! store (local-commit file-id))
(rx/push! response {:revn 1})
(await (async/wait-for #(empty? (get-in @store [:persistence :queue]))
"the first save lands"))
(await (async/settle))
(t/is (contains? @tasks :persistence) "the buffered edit keeps the guard on")
(ptk/emit! store ::dps/force-persist)
(await (async/wait-for #(= 2 (count @requests)) "the buffered edit is sent"))
(rx/push! response {:revn 2})
(await (async/wait-for #(not (contains? @tasks :persistence))
"the second save turns the guard off"))
(t/is (empty? (get-in @store [:persistence :queue])))))))
;; Scenario: a save fails terminally, then recovery releases the edit. The
;; guard stays on while the failed edit is retained, and turns off once it
;; is released. Proves: a release that empties the queue also turns the
;; leave-page warning off.
(t/deftest ^:async releasing-a-failed-edit-turns-the-leave-page-guard-off
(await
(with-persistence
(^:async fn [{:keys [file-id store tasks]}]
(ptk/emit! store (local-commit file-id)
#(assoc-in % [:permissions :can-edit] false)
::dps/force-persist)
(await (async/wait-for #(= :error (get-in @store [:persistence :status]))
"the save fails terminally"))
(await (async/settle))
(t/is (contains? @tasks :persistence) "the retained edit keeps the guard on")
(ptk/emit! store (ptk/data-event :app.main.data.common/change-team-role))
(await (async/wait-for #(empty? (get-in @store [:persistence :queue]))
"recovery releases the edit"))
(await (async/wait-for #(not (contains? @tasks :persistence))
"the release turns the guard off"))
(t/is (= :saved (get-in @store [:persistence :status])))))))
;; Scenario: an edit waits in the 3 s buffer and the user switches tabs or
;; windows. Proves: leaving sends the buffered edit at once, well before
;; the buffer would (the bounded `wait-for` gives up long before 3 s).
(t/deftest ^:async leaving-the-tab-sends-buffered-edits-at-once
(let [leave-s (rx/subject)]
(await
(mock/with-mocks*
{dps/leave-signals (fn [] leave-s)}
(await
(with-persistence
(^:async fn [{:keys [file-id requests store]}]
(ptk/emit! store (local-commit file-id))
(await (async/settle))
(t/is (empty? @requests) "the edit waits in the buffer")
(rx/push! leave-s :hidden)
(await (async/wait-for #(= 1 (count @requests))
"leaving sends the edit at once"))
(t/is (= 1 (count @requests))))))))))
;; Scenario: nothing is buffered when the user leaves. Proves: a leave
;; signal alone sends nothing.
(t/deftest ^:async leaving-with-nothing-buffered-sends-nothing
(let [leave-s (rx/subject)]
(await
(mock/with-mocks*
{dps/leave-signals (fn [] leave-s)}
(await
(with-persistence
(^:async fn [{:keys [requests store]}]
(rx/push! leave-s :hidden)
(await (async/settle))
(t/is (empty? @requests))
(t/is (nil? (get-in @store [:persistence :status]))))))))))
(defn- wait-files-saved
"Emits into `store` an event that runs `force-persist-files-and-wait` for
`file-ids` and then flips `done`."
[store file-ids timeout-ms done]
(ptk/emit! store
(ptk/reify ::wait-files-saved
ptk/WatchEvent
(watch [_ _ _]
(rx/concat
(dps/force-persist-files-and-wait file-ids timeout-ms)
(rx/of (ptk/reify ::files-saved
ptk/EffectEvent
(effect [_ _ _] (reset! done true)))))))))
;; Scenario: an edit waits in the buffer when an export asks for its file
;; to be saved. Proves: the wait sends the edit and only ends once the save
;; lands.
(t/deftest ^:async waiting-for-a-file-sends-and-waits-for-its-edits
(let [done (atom false)]
(await
(with-persistence
(^:async fn [{:keys [file-id response requests store]}]
(await
(mock/with-mocks*
{refs/persistence (l/derived :persistence store)}
(ptk/emit! store (local-commit file-id))
(wait-files-saved store #{file-id} 60000 done)
(await (async/wait-for #(= 1 (count @requests)) "the edit is sent at once"))
(await (async/settle))
(t/is (false? @done) "the wait holds while the save is in flight")
(rx/push! response {:revn 1})
(await (async/wait-for #(true? @done) "the landed save ends the wait"))
(t/is (not (dps/unsaved-changes? @store #{file-id}))))))))))
;; Scenario: a save of one file hangs while another file is exported.
;; Proves: the wait only covers the exported files.
(t/deftest ^:async waiting-for-a-file-ignores-other-files
(let [done (atom false)]
(await
(with-persistence
(^:async fn [{:keys [file-id requests store]}]
(await
(mock/with-mocks*
{refs/persistence (l/derived :persistence store)}
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(await (async/wait-for #(= 1 (count @requests)) "the other file's save hangs"))
(wait-files-saved store #{(uuid/next)} 60000 done)
(await (async/wait-for #(true? @done) "the wait ends at once")))))))))
;; Scenario: the file's save hangs past the wait. Proves: the wait gives up
;; without raising, and the file still reports unsaved changes.
(t/deftest ^:async waiting-for-a-file-gives-up-after-the-timeout
(let [done (atom false)]
(await
(with-persistence
(^:async fn [{:keys [file-id requests store]}]
(await
(mock/with-mocks*
{refs/persistence (l/derived :persistence store)}
(ptk/emit! store (local-commit file-id))
(wait-files-saved store #{file-id} 1 done)
(await (async/wait-for #(true? @done) "the wait times out"))
(t/is (= 1 (count @requests)))
(t/is (dps/unsaved-changes? @store #{file-id})))))))))
(defn- save-failed-events
"Starts collecting the `save-failed` audit events that `store` processes."
[store]
(let [events (atom [])]
(->> (ptk/input-stream store)
(rx/filter #(= ::ev/event (ptk/type %)))
(rx/map deref)
(rx/filter #(= "save-failed" (::ev/name %)))
(rx/subs! #(swap! events conj %)))
events))
;; Scenario: the backend rejects a save as invalid data. Proves: the user's
;; failure toast comes with one `save-failed` audit event that records why.
(t/deftest ^:async a-failed-save-emits-one-save-failed-event
(await
(with-persistence
(^:async fn [{:keys [file-id store]}]
(let [events (save-failed-events store)]
(ptk/emit! store (local-commit file-id) ::dps/force-persist)
(await (async/wait-for #(= :error (get-in @store [:persistence :status]))
"the save fails"))
(await (async/wait-for #(seq @events) "the event is emitted"))
(await (async/settle))
(t/is (= 1 (count @events)))
(t/is (= {:cause-type :validation
:code :referential-integrity
:retrying false
:queued-commits 1}
(select-keys (first @events)
[:cause-type :code :retrying :queued-commits])))))
(fn [_ _]
(rx/throw (ex-info "invalid" {:type :validation :code :referential-integrity}))))))