Skip to content
File

Blob: tests/worker/lib/page-tree.workers.test.ts

typescript184 lines
1import { beforeEach, describe, expect, it } from "vitest";
2 
3import { MAX_TREE_DEPTH } from "@/shared/constants";
4import {
5 getPageAncestorChain,
6 getPageAncestorDepthFromChain,
7 getPageSubtreeMaxDepth,
8 validatePageMove,
9} from "@/worker/lib/page-tree";
10import { getDb, resetD1Tables } from "@tests/worker/helpers/db";
11import { seedPage, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
12 
13async 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 
29describe("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});