| 1 | import assert from "node:assert/strict"; |
| 2 | import { readFileSync, readdirSync } from "node:fs"; |
| 3 | import { DatabaseSync } from "node:sqlite"; |
| 4 | import { test } from "node:test"; |
| 5 | |
| 6 | import type { Space } from "../who.ts"; |
| 7 | import { deleteSpaceRows, eraseFolios, landingFor } from "./trash.ts"; |
| 8 | |
| 9 | /** D1, as far as the trash uses it, over node's SQLite with the service's migrations. */ |
| 10 | function fakeD1(): D1Database & { raw: DatabaseSync } { |
| 11 | const db = new DatabaseSync(":memory:"); |
| 12 | const dir = new URL("../../migrations/", import.meta.url); |
| 13 | for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8")); |
| 14 | db.exec("PRAGMA foreign_keys = OFF"); |
| 15 | type Statement = { sql: string; params: unknown[] }; |
| 16 | const statement = (sql: string, params: unknown[] = []): Statement & Record<string, unknown> => ({ |
| 17 | sql, |
| 18 | params, |
| 19 | bind: (...values: unknown[]) => statement(sql, values), |
| 20 | first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null, |
| 21 | run: async () => db.prepare(sql).run(...(params as never[])), |
| 22 | all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }), |
| 23 | }); |
| 24 | return { |
| 25 | raw: db, |
| 26 | prepare: (sql: string) => statement(sql), |
| 27 | batch: async (statements: Statement[]) => statements.map((s) => db.prepare(s.sql).run(...(s.params as never[]))), |
| 28 | } as unknown as D1Database & { raw: DatabaseSync }; |
| 29 | } |
| 30 | |
| 31 | const AT = "2026-10-11T12:00:00.000Z"; |
| 32 | |
| 33 | function space(db: DatabaseSync, id: string, over: Partial<{ slug: string; is_default: number; archived_at: string | null; deleted_at: string | null }> = {}) { |
| 34 | db.prepare("INSERT INTO spaces (id, workspace_id, slug, name, kind, default_role, is_default, created_by, created_at, archived_at, deleted_at) VALUES (?, 'wsp_1', ?, ?, 'workspace', 'edit', ?, 'user:ana', ?, ?, ?)").run( |
| 35 | id, |
| 36 | over.slug ?? id, |
| 37 | id, |
| 38 | over.is_default ?? 0, |
| 39 | AT, |
| 40 | over.archived_at ?? null, |
| 41 | over.deleted_at ?? null, |
| 42 | ); |
| 43 | } |
| 44 | |
| 45 | function folio(db: DatabaseSync, id: string, over: Partial<{ space_id: string | null; parent_id: string | null; path: string; trashed_at: string | null }> = {}) { |
| 46 | const path = over.path ?? `/${id}/`; |
| 47 | db.prepare( |
| 48 | "INSERT INTO folios (id, workspace_id, kind, title, owner, space_id, parent_id, position, acl_root, path, created_by, created_at, updated_at, edited_at, trashed_at) VALUES (?, 'wsp_1', 'doc', ?, 'user:ana', ?, ?, 1, ?, ?, 'user:ana', ?, ?, ?, ?)", |
| 49 | ).run(id, id, over.space_id ?? null, over.parent_id ?? null, path.split("/").filter(Boolean)[0]!, path, AT, AT, AT, over.trashed_at ?? null); |
| 50 | db.prepare("INSERT INTO folios_fts (folio_id, kind, title, body) VALUES (?, 'doc', ?, '')").run(id, id); |
| 51 | } |
| 52 | |
| 53 | const ids = (db: DatabaseSync, sql: string) => (db.prepare(sql).all() as { id: string }[]).map((r) => r.id).sort(); |
| 54 | |
| 55 | test("erasing folios takes their rows and search rows, deepest first, and leaves the rest", async () => { |
| 56 | const d1 = fakeD1(); |
| 57 | const db = d1.raw; |
| 58 | folio(db, "top", { trashed_at: AT }); |
| 59 | folio(db, "kid", { parent_id: "top", path: "/top/kid/", trashed_at: AT }); |
| 60 | folio(db, "grandkid", { parent_id: "kid", path: "/top/kid/grandkid/", trashed_at: AT }); |
| 61 | folio(db, "other"); |
| 62 | const gone = await eraseFolios({ DB: d1 }, [ |
| 63 | { id: "top", path: "/top/" }, |
| 64 | { id: "kid", path: "/top/kid/" }, |
| 65 | { id: "grandkid", path: "/top/kid/grandkid/" }, |
| 66 | // Asked twice: goes once. |
| 67 | { id: "kid", path: "/top/kid/" }, |
| 68 | ]); |
| 69 | assert.deepEqual(gone, ["grandkid", "kid", "top"]); |
| 70 | assert.deepEqual(ids(db, "SELECT id FROM folios"), ["other"]); |
| 71 | assert.deepEqual(ids(db, "SELECT folio_id AS id FROM folios_fts"), ["other"]); |
| 72 | }); |
| 73 | |
| 74 | test("a child still alive under an erased folio goes to the top of where it was", async () => { |
| 75 | const d1 = fakeD1(); |
| 76 | const db = d1.raw; |
| 77 | folio(db, "top", { trashed_at: AT }); |
| 78 | folio(db, "alive", { parent_id: "top", path: "/top/alive/" }); |
| 79 | await eraseFolios({ DB: d1 }, [{ id: "top", path: "/top/" }]); |
| 80 | const alive = db.prepare("SELECT parent_id FROM folios WHERE id = 'alive'").get() as { parent_id: string | null }; |
| 81 | assert.equal(alive.parent_id, null); |
| 82 | }); |
| 83 | |
| 84 | test("erasing nothing does nothing", async () => { |
| 85 | const d1 = fakeD1(); |
| 86 | folio(d1.raw, "a"); |
| 87 | assert.deepEqual(await eraseFolios({ DB: d1 }, []), []); |
| 88 | assert.deepEqual(ids(d1.raw, "SELECT id FROM folios"), ["a"]); |
| 89 | }); |
| 90 | |
| 91 | test("deleting a space trashes what is in it together, frees its address and keeps its row marked", async () => { |
| 92 | const d1 = fakeD1(); |
| 93 | const db = d1.raw; |
| 94 | space(db, "spc_eng", { slug: "engineering" }); |
| 95 | space(db, "spc_other", { slug: "other" }); |
| 96 | folio(db, "a", { space_id: "spc_eng" }); |
| 97 | folio(db, "b", { space_id: "spc_eng", parent_id: "a", path: "/a/b/" }); |
| 98 | folio(db, "old", { space_id: "spc_eng", trashed_at: "2026-10-01T00:00:00.000Z" }); |
| 99 | folio(db, "elsewhere", { space_id: "spc_other" }); |
| 100 | folio(db, "private"); |
| 101 | db.prepare("INSERT INTO space_joins (space_id, user_id, position, joined_at) VALUES ('spc_eng', 'bo', 1, ?), ('spc_other', 'bo', 2, ?)").run(AT, AT); |
| 102 | const trashed = await deleteSpaceRows(d1, { id: "spc_eng", slug: "engineering" }, AT, "user:ana"); |
| 103 | assert.deepEqual(trashed.sort(), ["a", "b"]); |
| 104 | // Together: one trashed_at, so each top restores the whole of what went with it. |
| 105 | assert.deepEqual(ids(db, `SELECT id FROM folios WHERE trashed_at = '${AT}' AND trashed_by = 'user:ana'`), ["a", "b"]); |
| 106 | // What was already in the trash keeps its own time; other spaces and Private are untouched. |
| 107 | assert.equal((db.prepare("SELECT trashed_at FROM folios WHERE id = 'old'").get() as { trashed_at: string }).trashed_at, "2026-10-01T00:00:00.000Z"); |
| 108 | assert.deepEqual(ids(db, "SELECT id FROM folios WHERE trashed_at IS NULL"), ["elsewhere", "private"]); |
| 109 | // The row stays, marked, its address freed; the other space's joins stay. |
| 110 | const row = db.prepare("SELECT slug, deleted_at, deleted_by FROM spaces WHERE id = 'spc_eng'").get() as { slug: string; deleted_at: string; deleted_by: string }; |
| 111 | assert.deepEqual({ ...row }, { slug: "engineering~spc_eng", deleted_at: AT, deleted_by: "user:ana" }); |
| 112 | assert.deepEqual(ids(db, "SELECT space_id AS id FROM space_joins"), ["spc_other"]); |
| 113 | // A new space takes the address. |
| 114 | space(db, "spc_new", { slug: "engineering" }); |
| 115 | assert.deepEqual(ids(db, "SELECT id FROM spaces WHERE slug = 'engineering'"), ["spc_new"]); |
| 116 | }); |
| 117 | |
| 118 | test("a deleted General is still the workspace's default row, so it is never made again", async () => { |
| 119 | const d1 = fakeD1(); |
| 120 | const db = d1.raw; |
| 121 | space(db, "spc_general", { slug: "general", is_default: 1 }); |
| 122 | await deleteSpaceRows(d1, { id: "spc_general", slug: "general" }, AT, "user:ana"); |
| 123 | // What ensureDefault asks (src/who.ts, src/index.ts). |
| 124 | const found = db.prepare("SELECT id FROM spaces WHERE workspace_id = 'wsp_1' AND is_default = 1").get() as { id: string } | undefined; |
| 125 | assert.equal(found?.id, "spc_general"); |
| 126 | // And no second default can be made: the one-default index holds. |
| 127 | assert.throws(() => space(db, "spc_again", { slug: "general", is_default: 1 })); |
| 128 | // Deleting it again changes nothing. |
| 129 | assert.deepEqual(await deleteSpaceRows(d1, { id: "spc_general", slug: "general~spc_general" }, "2026-10-12T00:00:00.000Z", "user:bo"), []); |
| 130 | assert.equal((db.prepare("SELECT deleted_at FROM spaces WHERE id = 'spc_general'").get() as { deleted_at: string }).deleted_at, AT); |
| 131 | }); |
| 132 | |
| 133 | function aSpace(over: Partial<Space["row"]> & { role?: Space["role"] }): Space { |
| 134 | const { role = "edit", ...row } = over; |
| 135 | return { |
| 136 | row: { |
| 137 | id: "spc", |
| 138 | workspace_id: "wsp_1", |
| 139 | slug: "spc", |
| 140 | name: "Space", |
| 141 | description: null, |
| 142 | icon: null, |
| 143 | kind: "workspace", |
| 144 | team: null, |
| 145 | default_role: "edit", |
| 146 | agent_mode: "suggest", |
| 147 | editors_can_share: 0, |
| 148 | is_default: 0, |
| 149 | created_by: "user:ana", |
| 150 | created_at: AT, |
| 151 | archived_at: null, |
| 152 | deleted_at: null, |
| 153 | deleted_by: null, |
| 154 | ...row, |
| 155 | }, |
| 156 | members: [], |
| 157 | projects: [], |
| 158 | role, |
| 159 | }; |
| 160 | } |
| 161 | |
| 162 | test("a folio restored from a deleted space lands in General when it is live and the restorer may add to it", () => { |
| 163 | const general = aSpace({ id: "spc_general", is_default: 1 }); |
| 164 | assert.equal(landingFor([aSpace({ id: "spc_gone", deleted_at: AT }), general])?.row.id, "spc_general"); |
| 165 | // No General, an archived or deleted one, or one they can only read: its owner's Private. |
| 166 | assert.equal(landingFor([aSpace({ id: "spc_other" })]), null); |
| 167 | assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, archived_at: AT })]), null); |
| 168 | assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, deleted_at: AT })]), null); |
| 169 | assert.equal(landingFor([aSpace({ id: "spc_general", is_default: 1, role: "view" })]), null); |
| 170 | assert.equal(landingFor([]), null); |
| 171 | }); |