penpot/frontend/test/frontend_tests/main_errors_test.cljs
Andrey Antukh 34b24a9d9d Retry transient saves with backoff and reconnect notice
Classify save failures as transient or terminal (`transient-error?`
over the repo retryable types plus `:invalid-save-response`).
Transient failures keep the head commit queued under a new `:retrying`
status and resend it with backoff (2s/8s/20s, then terminal):
stamp rotation reuses the same `:commit-id`, the in-flight guard
prevents double-sends, and episode tokens silence stale timers.
One tagged reconnect notice per episode (hidden on save and on
terminal failure, silent recovery) plus a `:retrying` save-indicator
state; the browser `online` event and new edits resume the episode.
Terminal failures keep the exact `:error` path. Covers tasks 4, 6
and 7 with 31 persistence tests; updates the persistence memory.

Relates to #11724

AI-assisted-by: muse-spark-1.3-contributor
2026-09-23 12:05:24 +02:00

789 lines
39 KiB
Clojure
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;; 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.main-errors-test
"Unit tests for app.main.errors.
Tests cover:
- stale-asset-error? pure predicate
- exception->error-data pure transformer
- on-error re-entrancy guard prevents recursive invocations
- flash schedules async report and toast neither the report nor
ntf/show is emitted synchronously
- organization SSO recovery expired SSO sessions go back to the provider
- invalid-sso-config handler requires :organization-id to promote to :sso-error
- save failure notification sticky toast carrying the error report
- delegated save failures causes handled by the handler for their type"
(:require
[app.common.uuid :as uuid]
[app.main.data.event :as ev]
[app.main.data.persistence :as dps]
[app.main.data.workspace :as-alias dw]
[app.main.errors :as errors]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.store :as st]
[app.util.dom :as dom]
[app.util.i18n :as i18n]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]
[frontend-tests.helpers.async :as async]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
;; ---------------------------------------------------------------------------
;; stale-asset-error?
;; ---------------------------------------------------------------------------
(t/deftest stale-asset-error-nil
(t/testing "nil cause returns nil/falsy"
(t/is (not (errors/stale-asset-error? nil)))))
(t/deftest stale-asset-error-keyword-cst-undefined
(t/testing "error with $cljs$cst$ and 'is undefined' is recognised"
(let [err (js/Error. "foo$cljs$cst$bar is undefined")]
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
(t/deftest stale-asset-error-keyword-cst-null
(t/testing "error with $cljs$cst$ and 'is null' is recognised"
(let [err (js/Error. "foo$cljs$cst$bar is null")]
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
(t/deftest stale-asset-error-protocol-dispatch-undefined
(t/testing "error with $cljs$core$I and 'Cannot read properties of undefined' is recognised"
(let [err (js/Error. "Cannot read properties of undefined (reading '$cljs$core$IFn$_invoke$arity$1$')")]
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
(t/deftest stale-asset-error-not-a-function
(t/testing "error with $cljs$cst$ and 'is not a function' is recognised"
(let [err (js/Error. "foo$cljs$cst$bar is not a function")]
(t/is (true? (boolean (errors/stale-asset-error? err)))))))
(t/deftest stale-asset-error-unrelated-message
(t/testing "ordinary error without stale-asset signature is NOT recognised"
(let [err (js/Error. "Cannot read properties of undefined (reading 'foo')")]
(t/is (not (errors/stale-asset-error? err))))))
(t/deftest stale-asset-error-only-cst-no-undefined
(t/testing "error with $cljs$cst$ but no undefined/null/not-a-function keyword is not recognised"
(let [err (js/Error. "foo$cljs$cst$bar exploded")]
(t/is (not (errors/stale-asset-error? err))))))
;; ---------------------------------------------------------------------------
;; exception->error-data
;; ---------------------------------------------------------------------------
(t/deftest exception->error-data-plain-error
(t/testing "plain JS Error is converted to a data map with :hint and ::instance"
(let [err (js/Error. "something went wrong")
data (errors/exception->error-data err)]
(t/is (= "something went wrong" (:hint data)))
(t/is (identical? err (::errors/instance data))))))
(t/deftest exception->error-data-ex-info
(t/testing "ex-info error preserves existing :hint and attaches ::instance"
(let [err (ex-info "original" {:hint "my-hint" :type :network})
data (errors/exception->error-data err)]
(t/is (= "my-hint" (:hint data)))
(t/is (= :network (:type data)))
(t/is (identical? err (::errors/instance data))))))
(t/deftest exception->error-data-ex-info-no-hint
(t/testing "ex-info without :hint falls back to ex-message"
(let [err (ex-info "fallback message" {:type :validation})
data (errors/exception->error-data err)]
(t/is (= "fallback message" (:hint data))))))
;; Shared report-test helpers (copied from the governor suite so each
;; namespace stays self-contained).
(t/use-fixtures :each {:before #(errors/reset-report-governor!)})
(defn- error-cause
[& {:keys [type code hint]}]
(ex-info (or hint "boom")
(cond-> {}
(some? type) (assoc :type type)
(some? code) (assoc :code code)
(some? hint) (assoc :hint hint))))
(defn- capture-reports!
"Run `f` capturing the events emitted through `st/emit!`."
[f]
(let [events (atom [])]
(with-redefs [st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")]
(f)
@events)))
(defn- report-events
"The audit events (report emissions) out of everything collected through
the `st/emit!` double. Toasts are ungoverned by design — one per `flash`
call — so only the events typed as audit events count for emission
bounds. Discriminates by `ptk/type`, which is total (never throws),
because toasts are not derefable."
[events]
(filter #(= ::ev/event (ptk/type %)) events))
;; ---------------------------------------------------------------------------
;; Environment failures
;;
;; Connectivity and service failures are not application defects: they are
;; audit-only telemetry with a compact report and a dedicated toast.
;; ---------------------------------------------------------------------------
(t/deftest environment-error-classification
(t/testing "environment failures are recognised"
(doseq [type [:network :offline :bad-gateway :service-unavailable
:nitrate-unavailable :nitrate-not-configured]]
(t/is (true? (errors/environment-error? (error-cause :type type)))
(str "expected environment error: " type))))
(t/testing "application defects are not environment failures"
(doseq [type [:internal :assertion :persistence :validation :authentication]]
(t/is (false? (errors/environment-error? (error-cause :type type)))
(str "did not expect environment error: " type))))
(t/testing "causes without ex-data are not environment failures"
(t/is (false? (errors/environment-error? (js/Error. "plain failure"))))
(t/is (false? (errors/environment-error? nil)))))
(t/deftest ^:async generate-report-compact-omits-stack-data-and-last-events
(await
(mock/with-mocks*
{st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
(let [cause (ex-info "http error" {:type :offline
:hint "http error"
:uri "/api/rpc/command/update-file"
:headers {"x-session-id" "secret"}})
report (errors/generate-report cause {:format :compact})]
(t/is (string? report))
(t/is (str/includes? report "Hint:"))
(t/is (str/includes? report "http error"))
(t/is (str/includes? report ":offline"))
(t/is (str/includes? report "/api/rpc/command/update-file"))
(t/is (not (str/includes? report "Last events:")))
(t/is (not (str/includes? report "Data:")))
(t/is (not (str/includes? report "====")))
(t/is (not (str/includes? report "secret")))
;; Trailing settle: the body is synchronous, but `with-mocks*`
;; evaluates to a promise, so the body must settle one.
(await (async/settle))))))
(t/deftest ^:async generate-report-defaults-to-the-full-format
(await
(mock/with-mocks*
{st/format-last-events (mock/stub (fn [& _] "(stub last events)"))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")}
(let [report (errors/generate-report (error-cause :type :internal :hint "boom"))]
(t/is (str/includes? report "Last events:"))
(t/is (str/includes? report "(stub last events)"))
;; Trailing settle: the body is synchronous, but `with-mocks*`
;; evaluates to a promise, so the body must settle one.
(await (async/settle))))))
(t/deftest ^:async connectivity-handlers-report-governed-compact-audit-events
;; Scenario: a network failure and an offline status through the global
;; handler. Each is reported as a governed compact audit event plus its
;; connection toast. Proves: per type, report first and toast right after,
;; with no stack in the report and the dedicated toast message.
(doseq [type [:network :offline]]
(errors/reset-report-governor!)
(let [events (atom [])
cause (ex-info "http error" {:type type :hint "http error"})]
(await
(mock/with-mocks*
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
tm/schedule (mock/stub (fn [f] (f)))
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
(errors/on-error cause)
(await (async/settle))
;; `flash` emits the report first and schedules the toast right after.
(t/is (= 2 (count @events)) (str "unexpected event count for " type))
(let [report-event (first @events)
toast-event (second @events)
props (deref report-event)]
(t/is (= "handled-exception" (::ev/name props)))
(t/is (not (str/includes? (:report props) "Last events:")))
(t/is (= (i18n/tr "errors.connection-error")
(get-in (ptk/update toast-event {}) [:notification :content])))))))))
(t/deftest ^:async flash-keeps-the-canonical-event-name-and-derives-the-format
;; Scenario: an environment failure flashed as `:handled` and as
;; `:unhandled`. The audit event name is the canonical one requested by
;; the caller while only the payload format derives from the cause
;; (compact, with no stack and no header dump). Proves: per type, one
;; compact audit event under the requested name, with the `flash`
;; awaited to completion.
(let [cause (ex-info "http error" {:type :network
:hint "http error"
:headers {"x-session-id" "secret"}})]
(doseq [[type event-name] [[:handled "handled-exception"]
[:unhandled "unhandled-exception"]]]
(errors/reset-report-governor!)
(let [events (atom [])]
(await
(mock/with-mocks*
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
tm/schedule (mock/stub (fn [f] (mock/asap f)))
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
;; The event name is the canonical one requested by the caller;
;; only the payload format is derived from the cause.
(await (errors/flash :cause cause :type type))
(let [reports (report-events @events)]
(t/is (= 1 (count reports)) (str "unexpected event count for " type))
(let [props (deref (first reports))
report (:report props)]
(t/is (= event-name (::ev/name props)) (str "event name for " type))
(t/is (not (str/includes? report "Last events:")))
(t/is (not (str/includes? report "secret")))))))))))
(t/deftest ^:async offline-loop-is-governed-and-never-unhandled
;; Scenario: 10 000 offline errors through the global handler, replaying
;; a connectivity outage. Every occurrence is handled (never unhandled)
;; and the governor emits only the first. Proves: one `handled-exception`
;; audit event for the whole loop, observed after timers drain.
(let [events (atom [])
cause (ex-info "http error" {:type :offline :hint "http error"})]
(await
(mock/with-mocks*
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
tm/schedule (mock/stub (fn [f] (mock/asap f)))
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
(dotimes [_ 10000]
(errors/on-error cause))
(await (async/settle))
(t/is (= 1 (count (report-events @events))))
(t/is (= "handled-exception" (::ev/name (deref (first (report-events @events))))))))))
(t/deftest ^:async flash-persistence-uses-compact-reports-for-environment-failures
;; Scenario: a save failure caused by the environment. `flash-persistence`
;; delegates to `flash`, so the audit event carries a compact report
;; (context only: no stack, no header dump). Proves: one
;; `handled-exception` with a compact payload, observed after timers
;; drain.
(let [events (atom [])]
(await
(mock/with-mocks*
{st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
rt/get-current-href (constantly "https://penpot.example.com/#/workspace")
tm/schedule (mock/stub (fn [f] (mock/asap f)))
st/format-last-events (mock/stub (fn [& _] (throw (ex-info "must not be called" {}))))}
(errors/flash-persistence (ex-info "http error" {:type :offline
:hint "http error"
:headers {"x-session-id" "secret"}}))
(await (async/settle))
(let [reports (report-events @events)]
(t/is (= 1 (count reports)))
(let [props (deref (first reports))]
(t/is (= "handled-exception" (::ev/name props)))
(t/is (not (str/includes? (:report props) "Last events:")))
(t/is (not (str/includes? (:report props) "secret")))))))))
(t/deftest exception-page-reports-dedup-by-cause
(let [cause-a (error-cause :type :internal :code :unable-to-process-repository-response :hint "boom")
cause-b (error-cause :type :internal :code :other :hint "other")
events (capture-reports!
(fn []
(errors/submit-report :event-name "exception-page"
:report "report" :hint "boom" :cause cause-a)
(errors/submit-report :event-name "exception-page"
:report "report" :hint "different hint" :cause cause-a)
(errors/submit-report :event-name "exception-page"
:report "report" :hint "other" :cause cause-b)))]
(t/is (= 2 (count events)))))
(t/deftest reports-of-the-same-cause-under-different-names-do-not-coalesce
(let [cause (error-cause :type :internal :hint "boom")
events (capture-reports!
(fn []
(errors/submit-report :event-name "handled-exception"
:report "report" :hint "boom" :cause cause)
(errors/submit-report :event-name "unhandled-exception"
:report "report" :hint "boom" :cause cause)
(errors/submit-report :event-name "exception-page"
:report "report" :hint "boom" :cause cause)))]
(t/is (= 3 (count events)))))
;; ---------------------------------------------------------------------------
;; on-error dispatches to ptk/handle-error
;;
;; We use a dedicated test-only error type so we can add/remove a
;; defmethod without touching the real handlers.
;; ---------------------------------------------------------------------------
(def ^:private test-handled (atom nil))
(defmethod ptk/handle-error ::test-dispatch
[err]
(reset! test-handled err))
(t/deftest on-error-dispatches-map-error
(t/testing "on-error dispatches a map error to ptk/handle-error using its :type"
(reset! test-handled nil)
(errors/on-error {:type ::test-dispatch :hint "hello"})
(t/is (= ::test-dispatch (:type @test-handled)))
(t/is (= "hello" (:hint @test-handled)))))
(t/deftest on-error-wraps-exception-then-dispatches
(t/testing "on-error wraps a JS Error into error-data before dispatching"
(reset! test-handled nil)
(let [err (ex-info "wrapped" {:type ::test-dispatch})]
(errors/on-error err)
(t/is (= ::test-dispatch (:type @test-handled)))
(t/is (identical? err (::errors/instance @test-handled))))))
;; ---------------------------------------------------------------------------
;; on-error re-entrancy guard
;;
;; The guard is implemented via the `handling-error?` volatile inside
;; app.main.errors. We can verify its effect by registering a
;; handle-error method that itself calls on-error and checking that
;; only one invocation gets through.
;; ---------------------------------------------------------------------------
(def ^:private reentrant-call-count (atom 0))
(defmethod ptk/handle-error ::test-reentrant
[_err]
(swap! reentrant-call-count inc)
;; Simulate a secondary error inside the error handler
;; (e.g. the notification emit itself throws).
;; Without the re-entrancy guard this would recurse indefinitely.
(when (= 1 @reentrant-call-count)
(errors/on-error (ex-info "test" {:type ::test-reentrant :hint "secondary"}))))
(t/deftest on-error-reentrancy-guard-prevents-recursion
(t/testing "a second on-error call while handling an error is suppressed by the guard"
(reset! reentrant-call-count 0)
(errors/on-error (ex-info "test" {:type ::test-reentrant :hint "first"}))
;; The guard must have allowed only the first invocation through.
(t/is (= 1 @reentrant-call-count))))
;; ---------------------------------------------------------------------------
;; Expired organization SSO session
;;
;; The backend rejects SSO-guarded requests with an :authentication error
;; coded :nitrate-sso-required once the organization SSO session lapses.
;; The user must be sent back through the identity provider instead of
;; being told they have no access to the file.
;; ---------------------------------------------------------------------------
(def ^:private workspace-href
"https://penpot.example.com/#/workspace?team-id=b8f8bb52-8b70-8144-8004-4a5085f0bdc9")
(def ^:private organization-id "d1a4c0f2-2f36-8114-8006-1b0e6d9d0c11")
(defn- sso-required-error
[]
{:type :authentication
:code :nitrate-sso-required
:organization-id organization-id
:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"})
(t/deftest ^:async expired-organization-sso-navigates-to-identity-provider
(t/testing "the browser is sent to the identity provider instead of an error page"
(let [events (atom [])]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub
(fn [_command _params]
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href (constantly workspace-href)
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
(errors/on-error (sso-required-error))
(await (async/settle))
(t/is (= [::rt/nav-raw] (mapv ptk/type @events))))))))
(t/deftest ^:async expired-organization-sso-comes-back-to-the-current-location
(t/testing "the SSO check asks the provider to return the user where they were"
(let [rpc-calls (atom [])]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub
(fn [command params]
(swap! rpc-calls conj {:command command :params params})
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href (constantly workspace-href)
st/emit! mock/noop}
(errors/on-error (sso-required-error))
(await (async/settle))
(t/is (= [{:command :check-nitrate-sso
:params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"
:organization-id organization-id
:url workspace-href}}]
@rpc-calls)))))))
(t/deftest ^:async already-satisfied-organization-sso-retries-the-location
(t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring"
(let [events (atom [])]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub
(fn [_command _params]
(rx/of {:authorized true :reason :sso-satisfied})))
rt/get-current-href (constantly workspace-href)
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
(errors/on-error (sso-required-error))
(await (async/settle))
(t/is (= [::rt/reload] (mapv ptk/type @events))))))))
(t/deftest ^:async organization-sso-without-usable-provider-shows-the-sso-error-dialog
(t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error"
(let [assigned* (atom nil)]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub
(fn [_command _params]
(rx/of {:authorized false :redirect-uri nil})))
rt/get-current-href (constantly workspace-href)
rt/assign-exception (fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))}
(errors/on-error (sso-required-error))
(await (async/settle))
(t/is (= :sso-error (:type @assigned*)))
(t/is (= organization-id (:organization-id @assigned*)))
(t/is (true? (:is-workspace @assigned*))))))))
(t/deftest ^:async organization-sso-without-team-access-reports-a-permission-failure
(t/testing "a user who cannot reach the team keeps getting the authentication error"
(let [assigned* (atom nil)]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub
(fn [_command _params]
(rx/of {:authorized true :reason :no-team-access})))
rt/get-current-href (constantly workspace-href)
rt/assign-exception (fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))}
(errors/on-error (sso-required-error))
(await (async/settle))
(t/is (= :authentication (:type @assigned*)))
(t/is (= :nitrate-sso-required (:code @assigned*))))))))
(t/deftest ^:async organization-sso-does-not-retry-on-an-unexplained-authorization
(t/testing "reloading on an answer we don't understand would spin on the same rejection"
(let [events (atom [])]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [_command _params] (rx/of {:authorized true})))
rt/get-current-href (constantly workspace-href)
rt/assign-exception (fn [error] (ptk/data-event ::assigned error))
st/async-emit! (fn [& emitted] (swap! events into emitted))}
(errors/on-error (sso-required-error))
(await (async/settle))
(t/is (= [::assigned] (mapv ptk/type @events))))))))
(t/deftest ^:async organization-sso-error-without-context-is-reported-as-it-arrives
(t/testing "with no organization and no team there is nothing to check"
(let [rpc-calls (atom 0)
assigned* (atom nil)]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [_command _params]
(swap! rpc-calls inc)
(rx/empty)))
rt/get-current-href (constantly workspace-href)
rt/assign-exception (fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))}
(errors/on-error {:type :authentication
:code :nitrate-sso-required})
(await (async/settle))
(t/is (zero? @rpc-calls))
(t/is (= :nitrate-sso-required (:code @assigned*))))))))
(t/deftest ^:async a-resultless-organization-sso-check-does-not-wedge-later-rejections
(t/testing "the one-in-flight guard is released even when no answer arrives"
(let [rpc-calls (atom 0)]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub (fn [_command _params]
(swap! rpc-calls inc)
(rx/empty)))
rt/get-current-href (constantly workspace-href)
st/emit! mock/noop}
(errors/on-error (sso-required-error))
(errors/on-error (sso-required-error))
(await (async/settle))
(t/is (= 2 @rpc-calls)))))))
;; A failing check must stay a failing check: the generic handling turns it
;; into a toast, whereas swallowing it would show a permission error for
;; what may be a momentary network blip. The mocked RPC fails on a later
;; tick, like a real request, so the handler is not inside on-error's
;; re-entrancy guard when the failure arrives.
(def ^:private check-failures (atom []))
(defmethod ptk/handle-error ::test-check-failure
[error]
(swap! check-failures conj error))
(t/deftest ^:async failing-organization-sso-check-is-not-reported-as-missing-access
(t/testing "the SSO check fails like a real request (later tick) and the failure stays a failure"
(reset! check-failures [])
(let [assigned* (atom nil)]
(await
(mock/with-mocks*
{rp/cmd!
(mock/stub
(fn [_command _params]
(->> (rx/timer 0)
(rx/mapcat (fn [_]
(rx/throw (ex-info "boom" {:type ::test-check-failure})))))))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))}
(errors/on-error (sso-required-error))
(await (async/wait-for #(= 1 (count @check-failures))
"sso check failure observed"))
(t/is (= [::test-check-failure] (mapv :type @check-failures)))
(t/is (nil? @assigned*)))))))
;; ---------------------------------------------------------------------------
;; :validation / :invalid-sso-config
;;
;; The SSO error page needs an organization-id to retry meaningfully. Promote
;; to :sso-error only when that id is present; otherwise keep :validation so
;; we do not surface a broken SSO dialog for a future code path that omits it.
;; ---------------------------------------------------------------------------
(defn- capture-async-exception
"Invoke `ptk/handle-error` while capturing the error map passed to
`rt/assign-exception` via `st/async-emit!`.
`st/async-emit!` is variadic (`[& params]`); the mock must be too,
otherwise CLJS looks up `IFn$_invoke$arity$variadic` and throws."
[error]
(let [captured (atom nil)]
(with-redefs [st/async-emit! (fn [& events]
(reset! captured (first events)))
rt/assign-exception (fn [err] err)]
(ptk/handle-error error)
@captured)))
(t/deftest invalid-sso-config-with-organization-id-promotes-to-sso-error
(t/testing "invalid-sso-config with :organization-id is shown as :sso-error"
(let [org-id #uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
assigned (capture-async-exception
{:type :validation
:code :invalid-sso-config
:organization-id org-id
:hint "missing issuer"})]
(t/is (= :sso-error (:type assigned)))
(t/is (= org-id (:organization-id assigned)))
(t/is (= :invalid-sso-config (:code assigned))))))
(t/deftest invalid-sso-config-without-organization-id-keeps-validation
(t/testing "invalid-sso-config without :organization-id must not become :sso-error"
(let [assigned (capture-async-exception
{:type :validation
:code :invalid-sso-config
:hint "missing issuer"})]
(t/is (= :validation (:type assigned)))
(t/is (nil? (:organization-id assigned)))
(t/is (= :invalid-sso-config (:code assigned))))))
(t/deftest persistence-notifications-do-not-expire-but-other-flashes-do
(doseq [[notify timeout] [[#(errors/flash :hint "Ordinary error") 5000]
[#(errors/flash :hint "Custom error" :timeout 1000) 1000]
[#(errors/flash-persistence nil) nil]]]
(let [scheduled (atom [])
events (atom [])]
(with-redefs [tm/schedule (mock/stub #(swap! scheduled conj %))
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))]
(notify)
(t/is (empty? @events) "Keep notification delivery asynchronous")
(doseq [callback @scheduled] (callback))
(t/is (= 1 (count @events)))
(let [state (ptk/update (first @events) {})]
(t/is (= timeout (get-in state [:notification :timeout])))
(t/is (= :visible (get-in state [:notification :status]))))))))
(t/deftest ^:async persistence-notifications-include-an-error-report-download
(let [scheduled (atom [])
idle-callbacks (atom [])
events (atom [])
downloads (atom [])
revoked (atom [])
report "generated error report"
cause (ex-info "Save failed" {:type :validation})]
(await
(mock/with-mocks*
{dom/prevent-default (fn [_])
dom/trigger-download-uri (fn [& params]
(swap! downloads conj params))
errors/generate-report (fn [_ & _] report)
errors/submit-report (fn [& _])
;; `tr` is called with one and with two arguments, and its
;; two-argument arity is variadic: the stub exposes both
;; shapes so the compiled static calls resolve.
i18n/tr (fn ([key] (str key ":"))
([key & args]
(str key ":" (first args))))
st/emit! (mock/stub (fn [& emitted]
(swap! events into emitted)))
tm/schedule (mock/stub (fn [callback]
(swap! scheduled conj callback)))
tm/schedule-on-idle (mock/stub (fn [callback]
(swap! idle-callbacks conj callback)))
wapi/create-blob (mock/stub (fn [content media-type]
{:content content :media-type media-type}))
wapi/create-uri (fn [_] "blob:report")
wapi/revoke-uri (fn [uri]
(swap! revoked conj uri))}
(errors/flash-persistence cause)
(doseq [callback @scheduled] (callback))
(await (async/settle))
;; The report is emitted first and the toast right after, so the
;; toast carrying the download link is the last event collected.
(let [state (ptk/update (last @events) {})
download (get-in state [:notification :links 0])]
(t/is (= "labels.download:report.txt" (:label download)))
((:callback download) nil)
(t/is (= [["report" "text/plain" "blob:report"]] @downloads))
(doseq [callback @idle-callbacks] (callback))
(t/is (= ["blob:report"] @revoked)))))))
(t/deftest ^:async persistence-waiters-do-not-report-an-already-handled-failure
(let [reports (atom [])
rejected (atom [])
pstate (atom {:status :saving})
store (ptk/store {:state {} :on-error errors/on-error})
cause (ex-info "Save failed" {:type :network})]
(await
(mock/with-mocks*
{refs/persistence pstate
st/emit! (mock/stub (fn [& emitted]
(swap! reports into
(report-events emitted))))
;; Run the scheduled `flash` body synchronously: the whole flow
;; under test is synchronous except for the deferral.
tm/schedule (mock/stub (fn [f] (f)))}
(try
(ptk/emit! store (#'dps/persistence-failed (uuid/next) cause))
(reset! pstate (:persistence @store))
(dotimes [_ 2]
(->> (dps/wait-persisted-or-error)
(rx/subs! (fn [_] (t/is false "A failed save must still reject"))
(fn [error]
(swap! rejected conj error)
(errors/on-error error)))))
(await (async/settle))
(t/is (= 2 (count @rejected)))
(t/is (= 1 (count @reports)))
;; A standalone timeout and a later save failure are new incidents.
;; The later failure carries a distinct signature: repeating the same
;; one inside the governor window is coalesced by design.
(errors/on-error (ex-info "Save timed out" {:type :persistence :code :save-timeout}))
(await (async/settle))
(t/is (= 2 (count @reports)))
(ptk/emit! store (#'dps/persistence-failed (uuid/next)
(ex-info "Save failed again" {:type :network})))
(await (async/settle))
(t/is (= 3 (count @reports)))
(finally
(rx/dispose! store)))))))
;; ---------------------------------------------------------------------------
;; Save failures that belong to their own handler
;;
;; Some causes are not resolved by retaining the changes: the session has to
;; be renewed, the file is gone, or a different version was restored. Each
;; one is handled by the error handler for its own type.
;; ---------------------------------------------------------------------------
(t/deftest expired-session-during-save-goes-to-the-authentication-handler
(t/testing "a lost session is handled as an authentication error, not notified"
(let [assigned (atom nil)
scheduled (atom [])]
(with-redefs [rt/get-current-href (constantly workspace-href)
rt/assign-exception (fn [error] error)
st/async-emit! (fn [& events] (reset! assigned (first events)))
tm/schedule (mock/stub (fn [callback]
(swap! scheduled conj callback)))]
(errors/flash-persistence (ex-info "Session expired" {:type :authentication}))
(t/is (= :authentication (:type @assigned)))
(t/is (empty? @scheduled) "An expired session shows no save notification")))))
(t/deftest ^:async expired-organization-sso-during-save-renews-the-session
(t/testing "an SSO-guarded save failure goes back through the identity provider"
(let [events (atom [])]
(await
(mock/with-mocks*
{rp/cmd! (mock/stub
(fn [_command _params]
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href (constantly workspace-href)
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
(errors/flash-persistence (ex-info "SSO required" (sso-required-error)))
(await (async/settle))
(t/is (= [::rt/nav-raw] (mapv ptk/type @events))))))))
(t/deftest a-deleted-file-during-save-shows-the-exception-page
(t/testing "a file that no longer exists shows its page, not the save notification"
(let [events (atom [])
scheduled (atom [])]
(with-redefs [rt/assign-exception (fn [error] error)
st/emit! (mock/stub (fn [& emitted]
(swap! events into emitted)))
tm/schedule (mock/stub (fn [callback]
(swap! scheduled conj callback)))]
(errors/flash-persistence (ex-info "File not found" {:type :not-found}))
(doseq [callback @scheduled] (callback))
(t/is (= [:not-found] (mapv :type @events)))))))
(t/deftest a-restored-version-during-save-reloads-the-file
(t/testing "a version restored elsewhere reloads the file instead of notifying"
(let [events (atom [])
scheduled (atom [])]
(with-redefs [st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
tm/schedule (mock/stub (fn [callback]
(swap! scheduled conj callback)))]
(errors/flash-persistence (ex-info "A different version has been restored"
{:type :validation :code :vern-conflict}))
(t/is (= [::dw/reload-current-file] (mapv ptk/type @events)))
(t/is (empty? @scheduled) "A restored version shows no save notification")))))
(t/deftest ^:async other-validation-failures-during-save-keep-the-notification
(t/testing "a validation failure without a recovery of its own is still notified"
(let [events (atom [])
scheduled (atom [])]
(await
(mock/with-mocks*
{errors/generate-report (fn [_ & _] "generated error report")
errors/submit-report (fn [& _])
st/emit! (mock/stub (fn [& emitted]
(swap! events into emitted)))
tm/schedule (mock/stub (fn [callback]
(swap! scheduled conj callback)))}
(errors/flash-persistence (ex-info "Invalid data" {:type :validation
:code :invalid-data}))
(doseq [callback @scheduled] (callback))
(await (async/settle))
;; The report is emitted first and the toast right after: one
;; audit event plus the sticky toast carrying the report link.
(t/is (= 1 (count (report-events @events))))
(let [state (ptk/update (last @events) {})]
(t/is (nil? (get-in state [:notification :timeout])))
(t/is (some? (get-in state [:notification :links 0])))))))))