penpot/frontend/test/frontend_tests/data/persistence_test.cljs
Andrey Antukh d57cdb4588 ⏪ Backport persistence related fixes from staging (#12306)
* ✨ Add async test helpers and the with-mocks* macro

Backport of the test scaffolding that the persistence retry work needs,
taken from staging. Purely additive: no existing helper changes behavior,
so the suite result is unchanged (740 tests, 0 failures).

- frontend-tests.helpers.async: ->promise, await-response, settle,
  observe and wait-for. wait-for checks immediately and then polls one
  macrotask at a time, bounded, so it fails instead of hanging.
- mock/with-mocks*: installs mocks with set! and evaluates its body in a
  generated ^:async fn, so the test reads sequentially and there is no
  done to thread. Evaluates to a promise.
- mock/asap and mock/run-mocked: run-mocked awaits the body and restores
  the originals always, in that order, reporting a rejection or a
  non-promise return as an :error.

The matching change that made with-mocks defer its body via asap is NOT
backported: it would force all 58 existing uses across 14 files into
done-chained contexts, which is a separate refactor.

AI-assisted-by: space-bunny-free

* ✨ Add an error report governor with compact payloads

One browser session could emit an unbounded number of identical error
reports, and a dropped connection was reported as an application defect
with the full payload. Both are fixed here, in one place, because the
persistence retry work that follows depends on the new submit-report
contract.

- A report governor in app.main.errors. Each report carries a
  fingerprint (report-name|type|code|hint|first stack frame); the first
  occurrence is always emitted, repeats inside a 2 minute window are
  counted, and the next emitted report carries them as :occurrences. The
  cache is bounded by evicting the oldest entry, so memory stays fixed.
- flash reserves the report before generating it, so a suppressed
  occurrence never pays the report-building cost. Its whole body now
  runs inside one ts/schedule callback, a reporting or notification
  failure is logged instead of propagating, and it returns a total
  promise so tests can await it.
- generate-report accepts :format :compact|:full. The compact payload
  keeps the context header plus type, code and uri, and skips the
  stack, the ex-data dump (it can carry request headers) and the
  last-events list.
- environment-error-types is derived from rp/retryable-types, so the
  transport classification has a single source of truth. Environment
  failures are audit-only handled-exceptions and never reach the
  exception page or the alerts. :offline gets its own handler instead
  of falling through to :default.

:occurrences travels inside the audit event :props, which the backend
schema accepts as a free-form map, so no backend change is needed.

The dedup governor test file is new: 7 tests over the fingerprint, the
pure reservation decision, and the flash pipeline.

Relates to #11726, #11743

AI-assisted-by: space-bunny-free

* ♻️ Answer commit refusals with an empty stream

commit-changes used to return nil when it refused to build a commit,
which forced every caller that wanted to observe the outcome to
special-case nil. It now returns an empty stream, so a refusal and an
empty result look the same and callers can subscribe uniformly.

Only the refusal branch changes: the accepted branch already answered
with (rx/of commit). Potok treats a nil watch result as "nothing", so
both spellings were already correct at the store level.

The 57 call sites that wrap this event in rx/of are unaffected. They
hold the ::commit-changes event itself, not the value its watch
returns, so they keep processing exactly as before.

AI-assisted-by: space-bunny-free

* ✨ Retry transient saves with backoff and a reconnect notice

Any failed update-file landed on the same path: the queue halted in
:error, the retained changes were flushed away, and the user was told
to reload. A dropped connection and a revoked permission are not the
same thing, so they now take different paths.

transient-error? splits them, checking :type and :cause-type because
persistence-failed wraps the original cause. Transient failures enter
a :retrying episode: the head commit stays queued and resends with
backoff at 2s, 8s and 20s, then falls through to today's terminal path.
Everything else is terminal and keeps the exact :error path.

- Resends rotate the ::request-id stamp only once the old request has
  left active-requests, carry the same :commit-id, and never double
  send while one request is in flight.
- Retry timers carry an episode token; a timer whose episode was
  superseded stays silent instead of sending or failing.
- One tagged reconnect notice per episode, hidden by tag on save and on
  terminal failure. Recovery is silent.
- The browser online event re-enters the runner, but only for a live
  :retrying episode, guarded by exists? js/window.
- wait-persisted-or-error waits through :retrying instead of cutting the
  wait short.

The episode keeps its :run-id, so a new local edit only joins the queue.
Re-entering the runner on every edit would bypass the backoff and spend
one attempt per batched commit.

The retry test namespace is new: 13 tests over the classification, the
backoff schedule, stamp rotation, stale tokens and the notice lifecycle.

Relates to #11724

AI-assisted-by: space-bunny-free

* 🐛 Fix stalled saves that never answer and edits lost on leave

Two holes the retry work did not close.

An update-file request that never gets an answer kept its attempt
stamp forever, so the queue could not move and the user saw a saving
state that never resolved. The request now has a 7 minute timeout that
fails it as a transient network error, which hands it to the retry path
instead of blocking. The timeout is longer than the 5 minute stall
threshold on purpose: the stalled report must fire before the retry,
not instead of it.

The leave-page warning also had gaps. It now tracks unsaved edits
directly, through sync-unload-guard, which holds :persistence in
st/ongoing-tasks while edits sit in the buffer or the queue. Edits kept
in the queue after a failed save keep the guard on too, so closing the
tab warns instead of dropping them.

store.cljs guards its ongoing-tasks watcher with exists? js/window,
because the new tests exercise it without a window.

Four new tests: three for the guard across buffering, an in-flight save
and a retained edit, and one for a hung request that must resend and
save.

Closes #12223

AI-assisted-by: space-bunny-free

* 🐛 Skip the save when a missing font cannot be remapped

fix-deleted-fonts emitted text and typography changes even when no
replacement font existed, so opening a page that uses a deleted custom
font rewrote identical content and cleared position-data on every
visit. Every visit became a save with nothing to save.

Both loops now compare the remapped value with the original and emit
nothing when they match. The remapped path is unchanged.

Two new tests cover the no-replacement case and the remapped case.

Closes #12151

AI-assisted-by: space-bunny-free

* ✨ Add a debug helper that simulates an intermediary response

A save that never reaches the backend is hard to reproduce: you need a
proxy, a captive portal or a CDN in the way. This wraps fetch and
answers the named API method with a status and a body that carries no
Penpot error code, leaving every other request alone.

Without a duration the simulation is spent after answering one call.
With one it answers every matching call for that long, which is how to
watch the save retry, warn, keep retrying and finally recover.

  debug.simulateHttpError('update-file')
  debug.simulateHttpError('get-comment-threads', 403)
  debug.simulateHttpError('update-file', 524, 120)
  debug.clearHttpErrorSimulation()

Dev-only and self-contained: no production path reads it.

AI-assisted-by: space-bunny-free

* 🌐 Add the save retry and connection error strings

Three strings the new persistence and error paths need:

- errors.save-retrying, shown once per retry episode while the queue
  resends a queued save after a connection loss.
- workspace.header.retrying, the save indicator state for an episode
  in progress.
- errors.connection-error, shown by the network and offline handlers,
  which no longer fall through to the generic message.

Added to en.po only. The translations script also normalizes every
other locale and drops 439 strings that were already stale before this
work; that belongs in its own maintenance pass, not in a backport.

AI-assisted-by: space-bunny-free

* 🐛 Report save failures as unhandled when they are our defect

flash-persistence reported every failure it did not delegate as
handled-exception, including the ones that are plainly our fault. A
validation failure means the client sent something the server refused,
yet it landed in the audit log with a name that skips the internal
reports and the alerts. Nobody was paged.

The report type now follows the cause. :validation, :server-error and
:internal report as unhandled-exception, so they alert. The rest are
unchanged.

Two exclusions are deliberate:

- Environment failures stay handled. They reach this point after the
  retry budget is spent, and the user's connection is not our bug.
- :assertion stays handled, because its own handler already reports it
  that way and the two paths should not disagree.

The cause type is read from :cause-type first, since persistence-failed
wraps the original under :type :persistence. Reading :type alone would
match the wrapper every time and change nothing.

The user still gets the same sticky toast and report link; only the
audit event name moves.

The test that asserted a validation failure is "still notified" now
asserts which event name it gets, across the bare and wrapped shapes,
the environment types that must stay handled, and an unclassified one.

AI-assisted-by: space-bunny-free

* 🐛 Unblock persistence when opening a file after a terminal failure

A terminal save failure set the state to :error and left the queue
alone. recover-persistence only resumed for :save-permission-denied, so
for every other terminal code both of its branches were false and it
emitted nothing. Leaving and reopening the same file therefore changed
nothing: the editor stayed halted until a reload, which rebuilds the
whole store and loses the queue anyway.

The path had two jobs under one name. initialize-persistence fires when
the workspace view mounts and tears down the previous subscription;
recover-persistence fires when a file is opened or the role changes.
They are now separate: initialization picks up a queue left behind by a
previous session, and deliberately does not lift a halt, so undeliverable
edits are released when the user acts rather than when the view mounts.

Recovery lifts the halt by releasing what can never be saved.
release-failed-file drops every queued commit of the file that failed,
then resume-persistence clears the error and settles an empty queue to
:saved. Retaining them until that point is deliberate: it is what keeps
wait-persisted-or-error rejecting, which is what stops
workspace/versions.cljs from building a snapshot out of unsaved edits.
Releasing at failure time would make waiters resolve instead.

Permission loss is treated as permanent too, which the previous
exception got wrong. The backend raises :not-found for a missing edit
permission, so a resend does not succeed — it routes the user to the
exception page. The old branch made that reachable: it resumed the save
whenever the cached can-edit flag said yes, and the server disagreed.
The only case where the flag can return to true is a team switch, where
the queue is gone anyway, so nothing real is lost by releasing.

Because the queue is shared, dropping the failed file is also what lets
the other files' pending edits drain. One file that cannot be saved no
longer blocks everything else.

Tests: the failure retains the queue and notifies once, recovery
releases it without replaying, a failed file does not block a second
one, permission loss is released rather than resumed, and initialization
leaves a halt alone.

AI-assisted-by: space-bunny-free

* ✨ Offer a reload on the save failure toast

A terminal save failure stops autosave for the whole session, but the
toast asked the user to reload without offering the reload. It listed
five implied actions, none of them reachable from the banner, and it
never said that the changes could not be saved.

flash now takes a :links vector of actions, appended after the download
link that :report-link? produces, so a caller can add recovery actions
without reaching into the report handling. flash-persistence passes a
reload built from labels.reload-page and location.reload, the pattern
already used elsewhere in the app for a full restart.

The message now says what happened, what is lost, and what to do about
it: autosave has stopped for this session, the changes since the last
successful save cannot be saved, and either reloading or leaving the
file and opening it again restarts it. Naming both matters, because the
toast links are easy to miss.

Saying the edits are lost is not optional: recovery releases them, so
without it the user finds out when they come back to the file.

Translated in en.po and es.po only. The translations script would also
normalize all 40 locales and drop entries that were already stale, which
is a maintenance pass of its own.

The reload callback cannot be invoked under the test runner, since
js/location does not exist there, so it is covered by its label and by
being callable. Whether the reload works is a manual check.

AI-assisted-by: space-bunny-free

* 🐛 Classify the responses the backend never wrote

A reply written by a proxy instead of the backend carries no Penpot
error code, so handle-response raised it as :internal. A 504 on a save
skipped the retry cycle and stopped autosave at once, and because
:internal reports as unhandled, it raised an alert. In a standard
deployment this is how a slow save ends: nginx answers 504 after 300s,
before the client's 7 minute timeout fires.

The status now decides the type:

- 504 and the Cloudflare 520-524 family raise :gateway-error, which
  joins retryable-types. Saves retry with backoff like 502 and 503,
  GETs retry, and a save that spends its retries reports as handled,
  because the report rules derive from the same set.
- 429 raises :rate-limit, and any other error status
  :unexpected-response. Neither retries; both show a toast instead of
  the error page.
- :internal is kept for a response below 400 that does not decode.

Bodies we cannot read are cut to 500 characters, since proxy error
pages run to tens of kilobytes.

Tests: classification and retries per status, the transport handlers
keep the page, a 504 and a 524 built by the real handle-response go
through the save path and land on a retry, and the new types stay
handled once the retries are spent.

AI-assisted-by: claude-opus-5-5

* ✨ Keep retrying failed saves on a slow cycle

Once the three backoff retries were spent, a save that failed on the
connection stopped autosave like any terminal failure, and opening a
file again dropped its edits. A connection that comes back after a
minute lost the work all the same.

A transport failure that outlasts the backoff now arms a slow cycle:
one silent resend every 30s, until a save lands or 24h have passed
since the failure started. The cycle stops when persistence restarts,
the queue resumes another way, or the workspace closes.

A save that keeps failing slowly is not resent for a day. A gateway
that answers 504 after its own timeout may leave the backend applying
the save, so each resend can cost the whole save again, and the failing
file holds back every other file's queued edits. An attempt that fails
after 90s or more counts as a slow failure; at three, the slow cycle
stops sending the commit and the save fails as before. Fast failures,
such as being offline, never count.

- The first failure shows a toast that says the changes are kept and
  keep being sent, with no reload link, since a reload loses them. A
  save that lands hides it.
- Opening a file sends a queue still inside its retry window once
  more instead of releasing it. Failures outside the window, and every
  non-transport failure, are still released.
- A freshly loaded file gets back the queued edits of its version, so
  reopening it shows the changes that are still waiting to be saved. An
  edit that no longer applies to the loaded data leaves the file as the
  server sent it, so the file still opens; the edits stay queued.
- A commit whose earlier request is no longer in flight is simply sent
  again under the same commit id: changes are idempotent, so there is
  no unknown-outcome guard any more. A request still in flight still
  blocks a parallel send.

flash takes a :tag so the save failure toast can be hidden by tag.

Tests: the spent backoff arms the slow cycle, a 504 arms it, a terminal
failure does not, the cycle stops when the window closes or at the slow
failure limit, recovery resends instead of releasing, reopening restores
only the loaded file's edits and survives one that no longer applies,
and the retrying toast offers no reload.

* 🐛 Turn off the leave-page warning when failed edits are released

The leave-page warning only followed the queue on appends and discards.
Opening a file again releases the edits of a file that failed for good,
but that release changed the queue without either event, so the warning
stayed on with nothing left to save. Leaving the page asked about
unsaved changes until the next save went through.

The warning now also follows the release.

Tests: releasing the last failed edit turns the warning off.

* 🐛 Stop retrying failed saves outside the workspace

Leaving the workspace was meant to stop the slow retry cycle, but only
a cycle already armed stopped. A backoff timer kept firing after the
user left, and a backoff spent outside the workspace armed a slow cycle
that kept sending for up to a day from the dashboard. Those sends check
the edit permission of whatever team is open, so a switch to a team
where the user only views failed the queued edits for good, and the
next file they opened dropped them.

Leaving the workspace now suspends retries. A pending backoff timer
goes stale, and while suspended a transport failure schedules no
backoff, a spent backoff arms no slow cycle and the online event sends
nothing. The edits stay queued and a request in flight still lands.
Opening a file lifts the suspension and resends a retrying save whose
backoff was cut short; a role change does not lift it.

Tests: leaving stops a pending backoff, a failure after leaving
schedules no retry and ignores the online event, a backoff spent
outside the workspace arms no slow cycle, and opening a file saves the
held edit in each case.

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
2026-10-15 15:54:12 +02:00

991 lines
52 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.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.persistence :as dps]
[app.main.data.render-wasm :as drw]
[app.main.data.workspace :as dw]
[app.main.errors :as errors]
[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]
[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? {}))))
;; 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])))))))