fix(trash): cascade soft-delete and restore across folder subtree

Closes G9.

DELETE on a folder now flips is_trashed on every descendant folder
and file under it in a single CTE pipeline (lpath <@ root.lpath
covers the whole subtree via the GiST index). Previously only the
root row was flipped — descendants stayed live, directly addressable
via their full path, and confused desktop-sync tree walks that
expected the parent-collection 404 to imply the children were gone
too.

restore_from_trash mirrors the cascade: descendants where the
original_*_parent_id column is NULL are the ones we cascade-trashed,
so they get cascade-restored too. Descendants that were independently
trashed before the parent went to trash have original_*_parent_id
set, so they stay in trash and remain visible as top-level entries
in storage.trash_items.

No schema migration needed — both original_parent_id (folders) and
original_folder_id (files) were already nullable and already encoded
'where this came from when it was independently trashed'; using NULL
as the cascade-marker reuses that existing distinction cleanly.

Test G9 flipped from KNOWN BUG to assert every descendant 404s after
the parent DELETE; new G9b proves the inverse cascade by restoring
the trashed root and verifying every descendant comes back at its
original path.
This commit is contained in:
Edouard Vanbelle
2026-06-16 23:35:23 +02:00
parent 5e1c99e227
commit e2702ac673
2 changed files with 114 additions and 29 deletions
+42 -15
View File
@@ -256,7 +256,7 @@ pass "G8: DELETE → 204 + GET 404"
# descendant assertions below will trip and you can flip them
# to strict 404.
# ─────────────────────────────────────────────────────────────
echo " G9: DELETE folder (pinned: descendants currently orphan — KNOWN BUG)"
echo " G9: DELETE folder cascades soft-delete to descendants"
nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g9-tree/" > /dev/null
nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g9-tree/inner/" > /dev/null
put_nc_file "g9-tree/file.txt" "G9 file"
@@ -265,22 +265,49 @@ STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_FILES_BASE/g9-tre
[[ "$STATUS" == "204" ]] \
|| fail "G9: folder DELETE expected 204, got $STATUS"
# Folder itself: correctly 404.
# Folder itself: 404.
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/")" == "404" ]] \
|| fail "G9: folder still present after DELETE — that part should always be 404"
|| fail "G9: folder still resolvable after DELETE"
# Descendants: pin the current (buggy) "still alive" status.
# Either current 207 (bug) or future 404 (fix) is acceptable;
# anything else means something has drifted unexpectedly.
CHILD_STATUS=$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/file.txt")
DEEP_STATUS=$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/deep.txt")
if [[ "$CHILD_STATUS" == "207" && "$DEEP_STATUS" == "207" ]]; then
pass "G9: descendants still reachable (file=207, deep=207) — KNOWN BUG pinned: move_to_trash isn't recursive at the row level"
elif [[ "$CHILD_STATUS" == "404" && "$DEEP_STATUS" == "404" ]]; then
fail "G9: descendants now correctly 404 (file=$CHILD_STATUS, deep=$DEEP_STATUS) — bug is fixed, flip this case to strict 404 assertions."
else
fail "G9: mixed/unexpected descendant statuses (file=$CHILD_STATUS, deep=$DEEP_STATUS) — pin needs review"
fi
# Descendants must now also be 404 (cascade soft-delete reaches the
# whole subtree). Previous behaviour left them reachable at their
# full path while the parent was gone — a data-integrity drift that
# confused desktop-sync tree walks.
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/file.txt")" == "404" ]] \
|| fail "G9: direct-child file still resolvable after parent DELETE — cascade not working"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/")" == "404" ]] \
|| fail "G9: descendant folder still resolvable after parent DELETE — cascade not working"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/deep.txt")" == "404" ]] \
|| fail "G9: descendant file still resolvable after parent DELETE — cascade not working"
pass "G9: DELETE folder → 204, descendants all 404 (cascade reaches the whole subtree)"
# G9b — restore the trashed root and verify cascade-restore brings
# every descendant back with the same paths. Cascade-trashed
# descendants (original_parent_id IS NULL) get un-trashed; rows that
# were independently trashed before the folder went to trash stay
# trashed.
echo " G9b: restore the trashed g9-tree → cascade-restore reaches descendants"
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/")
G9_TRASHED_HREF=$(extract_response_href_containing "$BODY" "g9-tree")
G9_TRASHED_ID=$(basename "$G9_TRASHED_HREF")
[[ -n "$G9_TRASHED_ID" ]] || fail "G9b: trashed g9-tree not found via PROPFIND"
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
-H "Destination: $NC_FILES_BASE/g9-tree-restored/" \
"$NC_TRASH_BASE/$G9_TRASHED_ID")
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|| fail "G9b: restore expected 201/204, got $STATUS"
# The folder and ALL its descendants are reachable again at their
# original paths (restore goes to original location, not the
# Destination header).
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/")" == "207" ]] \
|| fail "G9b: root folder not back after restore"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/file.txt")" == "207" ]] \
|| fail "G9b: direct-child file not restored alongside parent"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/")" == "207" ]] \
|| fail "G9b: descendant folder not restored alongside parent"
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/deep.txt")" == "207" ]] \
|| fail "G9b: descendant file not restored alongside parent"
pass "G9b: restored g9-tree carries the whole subtree back"
# ═════════════════════════════════════════════════════════════
# Group K — Trashbin DAV (depends on G8's deletion above)