penpot/common/test/common_tests/files/validate_test.cljc
Andrey Antukh bb76b29fe2 🐛 Heal corrupt files in file-gc and fix its library loader
Teach the file-gc task to heal files whose stored data fails schema
or semantic validation instead of failing the nightly run: read once
(sanitize, migrate in memory, check the scheduled revn), run the
shared best-effort repair loop, then take the row lock and persist
the snapshot, marking the file trimmed. A new revision orphaned the
current file thumbnail (the list joins on revn), so a run that
changes bytes touches modified-at instead (the ETag covers it) and
no-op runs touch nothing; the change is detected against the decoded
pre-sanitize data, which get-file now returns as :orig-data. The
handler reports a result map with a keyword reason (:cleaned, :gone,
:stale-revn, :unfixable, :error) and the tiered-storage offload
submit runs outside the result handling, so a queue failure after a
good write stays truthful and only logs its own error. The
library-user scan moved out of the write transaction, so long scans
no longer trip idle-in-transaction timeouts.

Repair convergence lives in one shared loop used by file-gc, the
debug endpoint and the srepl helper: validate, apply the computed
repair changes and repeat up to ten times, returning the input
untouched when it throws or does not converge. file-gc revalidates
after the loop and raises :repair-did-not-converge, so a partial
cleanup is never stored as clean: the classifier reports :unfixable,
the file stays unmarked and the scheduler retries it. Validation
skips shapes without :shape-ref (copy roots legitimately carry
none), so the repair converges instead of crashing on the get-shape
precondition; a dedicated repair for copies that genuinely lost
their ref is future work.

Libraries load through the weak-map loader, and that loader was
broken: the threading macro passed :data to update as the map
itself, so every library load threw and the repair loop swallowed
the exception, silently skipping every repair for files with linked
libraries. The pipeline is now explicit: load, realize pointers
(they take [cfg file], so the reversed -> threading was returning
the Integrant system map) and then sanitize. Unconverted types broke
ref resolution (detaching copies) and hid real uses from
instance-of?, so libraries are sanitized on load, user files are
sanitized inside the library-user scan, and the srepl helper and the
debug endpoint sanitize too. Only migrate when migrations pend.

sanitize-file-data now returns the input instance unchanged when
neither the nil-entry drop nor the schema decode repairs anything:
lazy containers (objects-map) keep their identity and file-gc can
tell a real fix from a no-op by comparing with the pre-sanitize
data. The walk still happens on every call, and that cost is
documented on the loader.

Migration 0027 heals stored data durably with the same recipe the
read path applies: schema-decode unconverted types (precompiled
decoders, explicit component-objects pass) plus the legacy-57 nil
page cleanup that reappeared through a write path. clean-file keeps
only its post-decode fixes and documents the single sanitize pass.
Change-level guards reject :add-page without a valid page id
(structured error, not assert, so release CLJS still enforces it)
and conflicting id/page pairs.

Tests pin the contract with production-shaped corruption builders:
file-gc repair and the outcome matrix, loop convergence and give-up
branches, the :shape-ref guard both ways, page guards, sanitize
idempotence and identity (plain and objects-map), the 0027
migration, a remote copy resolved through the loaded library and the
non-convergence report; existing file suites move to the result map
through the run-file-gc! helper.

Closes #12433

Follow-up:
https://github.com/penpot/penpot/issues/12509

AI-assisted-by: muse-spark-1.3-contributor
AI-assisted-by: glm-5.3-flash
2026-10-23 09:07:35 +00:00

489 lines
24 KiB
Clojure
Raw Permalink 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 INC
(ns common-tests.files.validate-test
"Exhaustive tests for the change-scoped partial validation functions in
app.common.files.validate:
- validate-file-affected – returns nil or list of errors
- validate-file-affected! – same but raises on non-empty errors
The tests verify the scoping logic implemented by extract-affected-ids
(tested indirectly) by injecting controlled broken states into specific
pages / components and confirming that only the expected entities are
validated."
(:require
[app.common.files.validate :as cfv]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.file :as ctf]
[app.common.types.pages-list :as ctpl]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
;; ----------------------------------------------------------------
;; Test-local helpers
;; ----------------------------------------------------------------
(defn- inject-broken-child
"Add a reference to a non-existent shape ID to the root shape's
children list on the page identified by `page-label`.
This causes `check-parent-children` to report a :child-not-found
error whenever that page is validated."
[file page-label]
(let [page-id (thi/id page-label)
missing-id (uuid/next)]
(ctf/update-file-data
file
(fn [file-data]
(ctpl/update-page
file-data
page-id
(fn [page]
(let [root (ctst/get-shape page uuid/zero)]
(ctst/set-shape page (update root :shapes conj missing-id)))))))))
(defn- inject-broken-component
"Add a deleted component with `:objects nil` to the file.
This causes `check-component` to report a
:component-nil-objects-not-allowed error whenever that component is
validated. The component id is registered under `comp-label` in the
ids-map so callers can look it up with `(thi/id comp-label)`."
[file comp-label]
(let [comp-id (thi/new-id! comp-label)]
(ctf/update-file-data
file
(fn [file-data]
(assoc-in file-data [:components comp-id]
{:id comp-id
:name "broken-component"
:objects nil
:deleted true
:main-instance-id (uuid/next)
:main-instance-page (uuid/next)})))))
;; ----------------------------------------------------------------
;; 1. Feature gate
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-no-feature
(t/testing "returns nil when file does not have the components/v2 feature"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(assoc :features #{}))
page-id (thi/id :page1)
changes [{:type :add-obj :page-id page-id :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected file {} changes))))))
;; ----------------------------------------------------------------
;; 2. Empty changes – nothing to validate
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-empty-changes
(t/testing "returns nil when the changes list is empty"
(let [file (thf/sample-file :file1 :page-label :page1)]
(t/is (nil? (cfv/validate-file-affected file {} []))))))
;; ----------------------------------------------------------------
;; 3. Page-level scoping – add-obj / mod-obj / fix-obj key: :page-id
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-page-scoping-misses-untouched-page
(t/testing "add-obj on page1 does not validate broken page2"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
;; The change only touches page1
changes [{:type :add-obj :page-id page1-id :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "mod-obj on page1 does not validate broken page2"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
changes [{:type :mod-obj :page-id page1-id :id uuid/zero
:operations [{:type :set :attr :name :val "root"}]}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "fix-obj on page1 does not validate broken page2"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
changes [{:type :fix-obj :page-id page1-id :id (uuid/next)
:operations []}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes))))))
(t/deftest validate-file-affected-page-scoping-catches-error-on-touched-page
(t/testing "add-obj on broken page1 surfaces the error"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :add-obj :page-id page1-id :id (uuid/next)}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "mod-obj on broken page1 surfaces the error"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :mod-obj :page-id page1-id :id uuid/zero
:operations [{:type :set :attr :name :val "root"}]}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "reg-objects on broken page1 surfaces the error"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :reg-objects :page-id page1-id :shapes []}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "mov-objects on broken page1 surfaces the error"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :mov-objects :page-id page1-id :parent-id uuid/zero :shapes []}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors))))))
;; ----------------------------------------------------------------
;; 4. add-page / mod-page – scoped by :id (not :page-id)
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-add-page-scoped-by-id
(t/testing "add-page with :id=page2 validates page2 (broken → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page2-id (thi/id :page2)
changes [{:type :add-page :id page2-id}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "add-page with :id=page1 does not validate broken page2"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
changes [{:type :add-page :id page1-id}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "mod-page with :id=page2 validates page2 (broken → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page2-id (thi/id :page2)
changes [{:type :mod-page :id page2-id}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors))))))
;; ----------------------------------------------------------------
;; 5. del-obj – shape-level op scoped by :page-id
;; del-page / del-component / mov-page / purge-component – no-ops
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-del-obj-scopes-page
(t/testing "del-obj scopes its :page-id just like add-obj / mod-obj"
;; del-obj is in the same shape-level ops bucket and scopes by :page-id.
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :del-obj :page-id page1-id :id (uuid/next)}]]
;; page1 has an error and del-obj touches page1 → error surfaced
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "del-obj on page1 does not validate broken page2"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
changes [{:type :del-obj :page-id page1-id :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes))))))
(t/deftest validate-file-affected-noop-change-types
(t/testing "del-page produces no affected entries"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :del-page :id page1-id}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "del-component produces no affected entries"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
changes [{:type :del-component :id comp-id}]]
(t/is (nil? (cfv/validate-file-affected file {} changes)))))
(t/testing "mov-page produces no affected entries"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :mov-page :id page1-id}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "purge-component produces no affected entries"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
changes [{:type :purge-component :id comp-id}]]
(t/is (nil? (cfv/validate-file-affected file {} changes)))))
(t/testing "add-color (library change) produces no affected entries"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
changes [{:type :add-color :id (uuid/next) :color {}}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "mod-color (library change) produces no affected entries"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
changes [{:type :mod-color :id (uuid/next) :color {}}]]
(t/is (nil? (cfv/validate-file-affected file' {} changes))))))
;; ----------------------------------------------------------------
;; 6. add-component / mod-component – scoped by :id (component-id)
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-add-component-scoped-by-id
(t/testing "add-component :id=comp1 validates comp1 (broken → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
changes [{:type :add-component :id comp-id}]]
(let [errors (cfv/validate-file-affected file {} changes)]
(t/is (seq errors))
(t/is (some #(= :component-nil-objects-not-allowed (:code %)) errors)))))
(t/testing "add-component :id=other-id does not validate broken comp1"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
_ (thi/id :comp1)
changes [{:type :add-component :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected file {} changes)))))
(t/testing "mod-component :id=comp1 validates comp1 (broken → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
changes [{:type :mod-component :id comp-id}]]
(let [errors (cfv/validate-file-affected file {} changes)]
(t/is (seq errors))
(t/is (some #(= :component-nil-objects-not-allowed (:code %)) errors))))))
;; ----------------------------------------------------------------
;; 7. restore-component – scopes BOTH :id (component) and :page-id
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-restore-component-scopes-page
(t/testing "restore-component touches its :page-id (broken page → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :restore-component :id (uuid/next) :page-id page1-id}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "restore-component does not validate a page it does not reference"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
changes [{:type :restore-component :id (uuid/next) :page-id page1-id}]]
;; page2 has an error but the change only touches page1
(t/is (nil? (cfv/validate-file-affected file' {} changes))))))
(t/deftest validate-file-affected-restore-component-scopes-component
(t/testing "restore-component touches its component :id (broken component → errors)"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
page1-id (thi/id :page1)
changes [{:type :restore-component :id comp-id :page-id page1-id}]]
(let [errors (cfv/validate-file-affected file {} changes)]
(t/is (seq errors))
(t/is (some #(= :component-nil-objects-not-allowed (:code %)) errors))))))
;; ----------------------------------------------------------------
;; 8. Mixed changes – union of affected entities
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-mixed-changes-union
(t/testing "two changes on different pages: both pages are validated"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
;; page2 is broken; page1 is clean
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
page2-id (thi/id :page2)
;; Both pages are touched
changes [{:type :add-obj :page-id page1-id :id (uuid/next)}
{:type :mod-obj :page-id page2-id :id uuid/zero
:operations []}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
;; page2 is validated → error surfaced
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors)))))
(t/testing "del-page (true no-op) mixed with add-obj on page1: only page1 is validated"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(thf/add-sample-page :page2))
;; page2 is broken; page1 is clean
file' (inject-broken-child file :page2)
page1-id (thi/id :page1)
page2-id (thi/id :page2)
;; del-page on page2 is a no-op; add-obj on page1 scopes page1 only
changes [{:type :del-page :id page2-id}
{:type :add-obj :page-id page1-id :id (uuid/next)}]]
;; page2's error is NOT surfaced because del-page produces no scope
(t/is (nil? (cfv/validate-file-affected file' {} changes)))))
(t/testing "duplicate page-ids in changes are deduplicated (page validated once)"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
;; Three changes all touching the same page
changes [{:type :add-obj :page-id page1-id :id (uuid/next)}
{:type :mod-obj :page-id page1-id :id uuid/zero :operations []}
{:type :del-obj :page-id page1-id :id (uuid/next)}]]
;; del-obj excluded, add-obj + mod-obj both scope page1; error surfaced once
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
;; There should be exactly one :child-not-found (not duplicated)
(t/is (= 1 (count (filter #(= :child-not-found (:code %)) errors))))))))
;; ----------------------------------------------------------------
;; 9. reorder-children and component-context changes
;; ----------------------------------------------------------------
(t/deftest validate-file-affected-reorder-children
(t/testing "reorder-children on broken page surfaces the error"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :reorder-children :page-id page1-id :id uuid/zero :shapes []}]]
(let [errors (cfv/validate-file-affected file' {} changes)]
(t/is (seq errors))
(t/is (some #(= :child-not-found (:code %)) errors))))))
(t/deftest validate-file-affected-obj-in-component
(t/testing "add-obj with :component-id (not :page-id) scopes a component"
;; When a shape change carries :component-id instead of :page-id it
;; means the change happened inside a deleted component's object tree.
;; extract-affected-ids routes it to :component-ids.
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
comp-id (thi/id :comp1)
changes [{:type :add-obj :component-id comp-id :id (uuid/next)}]]
(let [errors (cfv/validate-file-affected file {} changes)]
(t/is (seq errors))
(t/is (some #(= :component-nil-objects-not-allowed (:code %)) errors)))))
(t/testing "add-obj with :component-id does not scope an unrelated component"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(inject-broken-component :comp1))
_ (thi/id :comp1)
changes [{:type :add-obj :component-id (uuid/next) :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected file {} changes))))))
;; ----------------------------------------------------------------
;; 10. validate-file-affected! – raises vs returns nil
;; ----------------------------------------------------------------
(t/deftest validate-file-affected!-returns-nil-on-clean-file
(t/testing "returns nil when the file has no errors in the touched page"
(let [file (thf/sample-file :file1 :page-label :page1)
page1-id (thi/id :page1)
changes [{:type :add-obj :page-id page1-id :id (uuid/next)}]]
(t/is (nil? (cfv/validate-file-affected! file {} changes))))))
(t/deftest validate-file-affected!-returns-nil-empty-changes
(t/testing "returns nil when there are no changes (no pages validated)"
(let [file (thf/sample-file :file1 :page-label :page1)]
(t/is (nil? (cfv/validate-file-affected! file {} []))))))
(t/deftest validate-file-affected!-raises-on-error
(t/testing "raises an exception when the touched page has validation errors"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :mod-obj :page-id page1-id :id uuid/zero
:operations []}]]
(t/is (thrown? #?(:clj Exception :cljs js/Error)
(cfv/validate-file-affected! file' {} changes)))))
(t/testing "raised exception is of :validation type with :referential-integrity code"
(let [file (thf/sample-file :file1 :page-label :page1)
file' (inject-broken-child file :page1)
page1-id (thi/id :page1)
changes [{:type :mod-obj :page-id page1-id :id uuid/zero
:operations []}]]
(try
(cfv/validate-file-affected! file' {} changes)
(t/is false "expected exception to be thrown")
(catch #?(:clj clojure.lang.ExceptionInfo :cljs cljs.core.ExceptionInfo) e
(let [data (ex-data e)]
(t/is (= :validation (:type data)))
(t/is (= :referential-integrity (:code data)))
(t/is (seq (:details data)))))))))
;; ----------------------------------------------------------------
;; 11. check-component-ref – copy roots without :shape-ref
;; ----------------------------------------------------------------
(defn- dissoc-copy-shape-ref
"Remove :shape-ref from the copy root shape. Instance heads
legitimately carry none (see get-ref-shape), so this mirrors the
stored shape instead of crafting corruption."
[file copy-label]
(let [page (thf/current-page file)
copy-id (thi/id copy-label)]
(ctf/update-file-data
file
(fn [file-data]
(ctpl/update-page file-data (:id page)
(fn [p]
(ctst/set-shape p (dissoc (ctst/get-shape p copy-id)
:shape-ref))))))))
(t/deftest validate-copy-without-shape-ref
(t/testing "a copy root without :shape-ref reports no :ref-shape-not-found"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(tho/add-simple-component-with-copy :component1 :main-root :main-child :copy-root))
file (dissoc-copy-shape-ref file :copy-root)
errors (cfv/validate-file file {})]
(t/is (empty? (filter #(= :ref-shape-not-found (:code %)) errors))
"nothing referenced, nothing to report"))))
(t/deftest validate-copy-with-unknown-shape-ref
(t/testing "a copy root with a dangling :shape-ref still reports :ref-shape-not-found"
(let [file (-> (thf/sample-file :file1 :page-label :page1)
(tho/add-simple-component-with-copy :component1 :main-root :main-child :copy-root))
file (ths/update-shape file :copy-root :shape-ref (uuid/next))
errors (cfv/validate-file file {})]
(t/is (some #(= :ref-shape-not-found (:code %)) errors)
"the guard did not swallow dangling references"))))