File
Blob: tests/worker/lib/page-tree.workers.test.ts
| 1 | import { beforeEach, describe, expect, it } from "vitest"; |
| 2 | |
| 3 | import { MAX_TREE_DEPTH } from "@/shared/constants"; |
| 4 | import { |
| 5 | getPageAncestorChain, |
| 6 | getPageAncestorDepthFromChain, |
| 7 | getPageSubtreeMaxDepth, |
| 8 | validatePageMove, |
| 9 | } from "@/worker/lib/page-tree"; |
| 10 | import { getDb, resetD1Tables } from "@tests/worker/helpers/db"; |
| 11 | import { seedPage, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds"; |
| 12 | |
| 13 | async function seedChain(workspaceId: string, createdBy: string, ids: string[]): Promise<void> { |
| 14 | // ids[0] is the leaf, ids[last] is the root. Pages are written from root to |
| 15 | // leaf so each parent exists before its child inserts the foreign-key row. |
| 16 | for (let i = ids.length - 1; i >= 0; i--) { |
| 17 | const parentId = i === ids.length - 1 ? null : ids[i + 1]; |
| 18 | await seedPage({ |
| 19 | id: ids[i], |
| 20 | workspace_id: workspaceId, |
| 21 | created_by: createdBy, |
| 22 | parent_id: parentId, |
| 23 | title: `Page ${i}`, |
| 24 | position: i, |
| 25 | }); |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | describe("worker page tree helpers (real D1)", () => { |
| 30 | beforeEach(async () => { |
| 31 | await resetD1Tables(); |
| 32 | }); |
| 33 | |
| 34 | it("returns ancestor rows from the recursive query helper", async () => { |
| 35 | const user = await seedUser(); |
| 36 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 37 | await seedChain(ws.id, user.id, ["leaf", "mid", "root"]); |
| 38 | |
| 39 | const chain = await getPageAncestorChain(getDb(), "leaf", ws.id); |
| 40 | expect(chain.map((row) => ({ id: row.id, parent_id: row.parent_id, depth: row.depth }))).toEqual([ |
| 41 | { id: "leaf", parent_id: "mid", depth: 0 }, |
| 42 | { id: "mid", parent_id: "root", depth: 1 }, |
| 43 | { id: "root", parent_id: null, depth: 2 }, |
| 44 | ]); |
| 45 | }); |
| 46 | |
| 47 | it("computes ancestor depth from a valid chain", async () => { |
| 48 | const user = await seedUser(); |
| 49 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 50 | await seedChain(ws.id, user.id, ["leaf", "mid", "root"]); |
| 51 | |
| 52 | const chain = await getPageAncestorChain(getDb(), "leaf", ws.id); |
| 53 | expect(getPageAncestorDepthFromChain(chain)).toBe(2); |
| 54 | }); |
| 55 | |
| 56 | it("returns zero ancestor depth for a top-level page", async () => { |
| 57 | const user = await seedUser(); |
| 58 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 59 | const page = await seedPage({ workspace_id: ws.id, created_by: user.id }); |
| 60 | |
| 61 | const chain = await getPageAncestorChain(getDb(), page.id, ws.id); |
| 62 | expect(getPageAncestorDepthFromChain(chain)).toBe(0); |
| 63 | }); |
| 64 | |
| 65 | it("rejects chains that still have more parents above the depth limit", async () => { |
| 66 | // Synthetic chain: chain.length = MAX_TREE_DEPTH + 1 with a non-null |
| 67 | // parent_id on the final row. That shape signals a truncated recursive |
| 68 | // query, so the helper must reject with null. |
| 69 | const chain = Array.from({ length: MAX_TREE_DEPTH + 1 }, (_, index) => ({ |
| 70 | id: `p-${index}`, |
| 71 | parent_id: index === MAX_TREE_DEPTH ? "above-limit" : `p-${index + 1}`, |
| 72 | title: `Page ${index}`, |
| 73 | icon: null, |
| 74 | depth: index, |
| 75 | })); |
| 76 | expect(getPageAncestorDepthFromChain(chain)).toBeNull(); |
| 77 | }); |
| 78 | |
| 79 | it("reads subtree depth from the recursive descendants query", async () => { |
| 80 | const user = await seedUser(); |
| 81 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 82 | const root = await seedPage({ workspace_id: ws.id, created_by: user.id, title: "root" }); |
| 83 | const a = await seedPage({ workspace_id: ws.id, created_by: user.id, parent_id: root.id, title: "a" }); |
| 84 | const b = await seedPage({ workspace_id: ws.id, created_by: user.id, parent_id: a.id, title: "b" }); |
| 85 | await seedPage({ workspace_id: ws.id, created_by: user.id, parent_id: b.id, title: "c" }); |
| 86 | |
| 87 | await expect(getPageSubtreeMaxDepth(getDb(), root.id, ws.id)).resolves.toBe(3); |
| 88 | }); |
| 89 | |
| 90 | it("counts archived descendants when reading subtree depth", async () => { |
| 91 | const user = await seedUser(); |
| 92 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 93 | const root = await seedPage({ workspace_id: ws.id, created_by: user.id, title: "root" }); |
| 94 | const archivedChild = await seedPage({ |
| 95 | workspace_id: ws.id, |
| 96 | created_by: user.id, |
| 97 | parent_id: root.id, |
| 98 | title: "hidden", |
| 99 | archived_at: "2026-04-01T00:00:00.000Z", |
| 100 | archive_root_id: root.id, |
| 101 | }); |
| 102 | await seedPage({ workspace_id: ws.id, created_by: user.id, parent_id: archivedChild.id, title: "hidden leaf" }); |
| 103 | |
| 104 | await expect(getPageSubtreeMaxDepth(getDb(), root.id, ws.id)).resolves.toBe(2); |
| 105 | }); |
| 106 | |
| 107 | it("returns zero subtree depth for a leaf page with no descendants", async () => { |
| 108 | const user = await seedUser(); |
| 109 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 110 | const page = await seedPage({ workspace_id: ws.id, created_by: user.id }); |
| 111 | |
| 112 | await expect(getPageSubtreeMaxDepth(getDb(), page.id, ws.id)).resolves.toBe(0); |
| 113 | }); |
| 114 | |
| 115 | it("rejects moving a page under itself without querying descendants", async () => { |
| 116 | const user = await seedUser(); |
| 117 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 118 | const page = await seedPage({ workspace_id: ws.id, created_by: user.id }); |
| 119 | |
| 120 | await expect(validatePageMove(getDb(), page.id, page.id, ws.id)).resolves.toEqual({ |
| 121 | ok: false, |
| 122 | reason: "self_parent", |
| 123 | }); |
| 124 | }); |
| 125 | |
| 126 | it("rejects moving a page under one of its descendants", async () => { |
| 127 | const user = await seedUser(); |
| 128 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 129 | const root = await seedPage({ workspace_id: ws.id, created_by: user.id, title: "root" }); |
| 130 | const child = await seedPage({ workspace_id: ws.id, created_by: user.id, parent_id: root.id, title: "child" }); |
| 131 | |
| 132 | await expect(validatePageMove(getDb(), root.id, child.id, ws.id)).resolves.toEqual({ |
| 133 | ok: false, |
| 134 | reason: "cycle", |
| 135 | }); |
| 136 | }); |
| 137 | |
| 138 | it("rejects moves that would exceed the maximum nesting depth", async () => { |
| 139 | const user = await seedUser(); |
| 140 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 141 | // Build a long ancestor chain (MAX_TREE_DEPTH + 2 levels from root to new-parent) |
| 142 | // so that adding any subtree would exceed MAX_TREE_DEPTH. |
| 143 | const chainIds = Array.from({ length: MAX_TREE_DEPTH + 1 }, (_, i) => `anc-${i}`); |
| 144 | await seedChain(ws.id, user.id, chainIds); |
| 145 | const movable = await seedPage({ workspace_id: ws.id, created_by: user.id, title: "movable" }); |
| 146 | |
| 147 | await expect(validatePageMove(getDb(), movable.id, chainIds[0], ws.id)).resolves.toEqual({ |
| 148 | ok: false, |
| 149 | reason: "depth_exceeded", |
| 150 | }); |
| 151 | }); |
| 152 | |
| 153 | it("accepts moves that stay within the depth limit", async () => { |
| 154 | const user = await seedUser(); |
| 155 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 156 | const root = await seedPage({ workspace_id: ws.id, created_by: user.id, title: "root" }); |
| 157 | const mid = await seedPage({ workspace_id: ws.id, created_by: user.id, parent_id: root.id, title: "mid" }); |
| 158 | const movable = await seedPage({ workspace_id: ws.id, created_by: user.id, title: "movable" }); |
| 159 | |
| 160 | await expect(validatePageMove(getDb(), movable.id, mid.id, ws.id)).resolves.toEqual({ ok: true }); |
| 161 | }); |
| 162 | |
| 163 | it("rejects moves that would exceed depth after restoring archived descendants", async () => { |
| 164 | const user = await seedUser(); |
| 165 | const ws = await seedWorkspace({ owner_id: user.id }); |
| 166 | const chainIds = Array.from({ length: MAX_TREE_DEPTH - 1 }, (_, i) => `anc-${i}`); |
| 167 | await seedChain(ws.id, user.id, chainIds); |
| 168 | const movable = await seedPage({ workspace_id: ws.id, created_by: user.id, title: "movable" }); |
| 169 | await seedPage({ |
| 170 | workspace_id: ws.id, |
| 171 | created_by: user.id, |
| 172 | parent_id: movable.id, |
| 173 | title: "archived child", |
| 174 | archived_at: "2026-04-01T00:00:00.000Z", |
| 175 | archive_root_id: movable.id, |
| 176 | }); |
| 177 | |
| 178 | await expect(validatePageMove(getDb(), movable.id, chainIds[0], ws.id)).resolves.toEqual({ |
| 179 | ok: false, |
| 180 | reason: "depth_exceeded", |
| 181 | }); |
| 182 | }); |
| 183 | }); |