284 lines
12 KiB
JavaScript
284 lines
12 KiB
JavaScript
import { describe, expect, test } from "bun:test";
|
|
import { Database } from "bun:sqlite";
|
|
import { globSync, readFileSync } from "node:fs";
|
|
import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts";
|
|
import { deleteNotebookRecord, isInboxNotebook, updateNotebookRecord } from "./notebook-service.ts";
|
|
import { workspaceInboxId } from "./workspace-provisioning.ts";
|
|
|
|
const actor = { actorType: "user", actorId: "usr_1" };
|
|
|
|
const createFixture = () => {
|
|
const sqlite = new Database(":memory:");
|
|
sqlite.exec(`
|
|
CREATE TABLE notebooks (
|
|
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, parent_id TEXT, name TEXT NOT NULL,
|
|
slug TEXT, icon TEXT, color TEXT, sort_order INTEGER NOT NULL DEFAULT 0,
|
|
is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
deleted_at TEXT
|
|
);
|
|
CREATE TABLE memos (
|
|
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, notebook_id TEXT NOT NULL, title TEXT,
|
|
is_deleted INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE audit_events (
|
|
id TEXT PRIMARY KEY, actor_type TEXT NOT NULL, actor_id TEXT, action TEXT NOT NULL,
|
|
entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, metadata_json TEXT NOT NULL, created_at TEXT NOT NULL
|
|
);
|
|
INSERT INTO notebooks (id, workspace_id, name, slug, is_deleted, created_at, updated_at) VALUES
|
|
('ws_1_inbox', 'ws_1', '等待分类', 'inbox', 0, '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z'),
|
|
('nb_notes', 'ws_1', 'Notes', 'notes', 0, '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z');
|
|
`);
|
|
return { sqlite, db: createSelfHostedStorageAdapter(sqlite, "/tmp/edgeever-notebook-service-test-unused").db };
|
|
};
|
|
|
|
describe("notebook identity", () => {
|
|
test("recognizes legacy, workspace-scoped, and slug inbox identities", () => {
|
|
expect(isInboxNotebook({ id: "nb_inbox", slug: "notes" }, "ws_1")).toBe(true);
|
|
expect(isInboxNotebook({ id: workspaceInboxId("ws_1"), slug: "shou-ji-xiang" }, "ws_1")).toBe(true);
|
|
expect(isInboxNotebook({ id: "nb_other", slug: "inbox" }, "ws_1")).toBe(true);
|
|
expect(isInboxNotebook({ id: "nb_notes", slug: "notes" }, "ws_1")).toBe(false);
|
|
});
|
|
|
|
test("keeps the inbox slug when the default notebook is renamed", async () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
const notebook = await updateNotebookRecord(
|
|
fixture.db,
|
|
"ws_1",
|
|
"ws_1_inbox",
|
|
{ name: "收集箱" },
|
|
actor,
|
|
);
|
|
expect(notebook).toMatchObject({ id: "ws_1_inbox", name: "收集箱", slug: "inbox" });
|
|
} finally {
|
|
fixture.sqlite.close();
|
|
}
|
|
});
|
|
|
|
test("migration 0046 restores renamed, deleted, and missing inbox identities", () => {
|
|
const sqlite = new Database(":memory:");
|
|
try {
|
|
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
for (const file of globSync("migrations/*.sql").sort().filter((file) => file.split("/").at(-1) < "0046")) {
|
|
sqlite.exec(readFileSync(file, "utf8"));
|
|
}
|
|
sqlite.exec(`
|
|
UPDATE notebooks SET name = '收集箱', slug = 'shou-ji-xiang' WHERE id = 'nb_inbox';
|
|
INSERT INTO workspaces (id, name) VALUES ('ws_empty', 'Empty');
|
|
INSERT INTO workspaces (id, name) VALUES ('ws_trashed', 'Trashed inbox');
|
|
INSERT INTO notebooks (id, workspace_id, name, slug, is_deleted, deleted_at)
|
|
VALUES ('ws_trashed_inbox', 'ws_trashed', '等待分类', 'inbox', 1, '2026-09-10T00:00:00.000Z');
|
|
`);
|
|
sqlite.exec(readFileSync("migrations/0046_restore_workspace_inbox_identity.sql", "utf8"));
|
|
expect(sqlite.query("SELECT name, slug, is_deleted FROM notebooks WHERE id = 'nb_inbox'").get()).toEqual({
|
|
name: "收集箱",
|
|
slug: "inbox",
|
|
is_deleted: 0,
|
|
});
|
|
expect(sqlite.query("SELECT slug, is_deleted FROM notebooks WHERE id = 'ws_trashed_inbox'").get()).toEqual({
|
|
slug: "inbox",
|
|
is_deleted: 0,
|
|
});
|
|
expect(sqlite.query("SELECT name, slug FROM notebooks WHERE id = 'ws_empty_inbox'").get()).toEqual({
|
|
name: "等待分类",
|
|
slug: "inbox",
|
|
});
|
|
expect(sqlite.query("PRAGMA foreign_key_check").all()).toEqual([]);
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
});
|
|
|
|
test("migration 0053 collapses a second inbox created beside a historical slug inbox", () => {
|
|
const sqlite = new Database(":memory:");
|
|
try {
|
|
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
for (const file of globSync("migrations/*.sql").sort().filter((file) => file.split("/").at(-1) < "0046")) {
|
|
sqlite.exec(readFileSync(file, "utf8"));
|
|
}
|
|
sqlite.exec(`
|
|
INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order)
|
|
VALUES (
|
|
'nb_7f802977e3cc4e8eb30ef3665955ed39',
|
|
'ws_default',
|
|
NULL,
|
|
'等待分类',
|
|
'inbox',
|
|
'notebook',
|
|
'#0f766e',
|
|
10
|
|
);
|
|
UPDATE memos SET notebook_id = 'nb_7f802977e3cc4e8eb30ef3665955ed39' WHERE notebook_id = 'nb_inbox';
|
|
DELETE FROM notebooks WHERE id = 'nb_inbox';
|
|
INSERT INTO memos (id, workspace_id, notebook_id, title, excerpt)
|
|
VALUES (
|
|
'memo_historical_inbox',
|
|
'ws_default',
|
|
'nb_7f802977e3cc4e8eb30ef3665955ed39',
|
|
'Kept',
|
|
'kept'
|
|
);
|
|
`);
|
|
sqlite.exec(readFileSync("migrations/0046_restore_workspace_inbox_identity.sql", "utf8"));
|
|
expect(
|
|
sqlite.query(`
|
|
SELECT id, is_deleted FROM notebooks
|
|
WHERE workspace_id = 'ws_default'
|
|
AND (slug = 'inbox' OR id = 'nb_inbox' OR id = workspace_id || '_inbox')
|
|
ORDER BY id
|
|
`).all(),
|
|
).toEqual([
|
|
{ id: "nb_7f802977e3cc4e8eb30ef3665955ed39", is_deleted: 0 },
|
|
{ id: "ws_default_inbox", is_deleted: 0 },
|
|
]);
|
|
|
|
sqlite.exec(readFileSync("migrations/0053_collapse_duplicate_inbox_notebooks.sql", "utf8"));
|
|
sqlite.exec(readFileSync("migrations/0053_collapse_duplicate_inbox_notebooks.sql", "utf8"));
|
|
|
|
expect(
|
|
sqlite.query(`
|
|
SELECT id, is_deleted FROM notebooks
|
|
WHERE workspace_id = 'ws_default'
|
|
AND (slug = 'inbox' OR id = 'nb_inbox' OR id = workspace_id || '_inbox')
|
|
ORDER BY is_deleted, id
|
|
`).all(),
|
|
).toEqual([
|
|
{ id: "nb_7f802977e3cc4e8eb30ef3665955ed39", is_deleted: 0 },
|
|
{ id: "ws_default_inbox", is_deleted: 1 },
|
|
]);
|
|
expect(
|
|
sqlite.query(`
|
|
SELECT notebook_id, COUNT(*) AS count
|
|
FROM memos
|
|
WHERE workspace_id = 'ws_default' AND is_deleted = 0
|
|
GROUP BY notebook_id
|
|
`).all(),
|
|
).toEqual([
|
|
{ notebook_id: "nb_7f802977e3cc4e8eb30ef3665955ed39", count: 2 },
|
|
]);
|
|
expect(sqlite.query("SELECT COUNT(*) AS count FROM notebooks WHERE is_deleted = 0 AND slug = 'inbox'").get()).toEqual({
|
|
count: 1,
|
|
});
|
|
expect(
|
|
sqlite.query(`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type = 'index' AND name = 'idx_notebooks_one_active_inbox'
|
|
`).get(),
|
|
).toEqual({ name: "idx_notebooks_one_active_inbox" });
|
|
expect(() => sqlite.exec(`
|
|
INSERT INTO notebooks (id, workspace_id, name, slug)
|
|
VALUES ('nb_second_inbox', 'ws_default', '等待分类', 'inbox')
|
|
`)).toThrow(/unique/i);
|
|
expect(sqlite.query("PRAGMA foreign_key_check").all()).toEqual([]);
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
});
|
|
|
|
test("migration 0053 moves notes off the empty duplicate and keeps child notebooks", () => {
|
|
const sqlite = new Database(":memory:");
|
|
try {
|
|
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
for (const file of globSync("migrations/*.sql").sort().filter((file) => file.split("/").at(-1) < "0053")) {
|
|
sqlite.exec(readFileSync(file, "utf8"));
|
|
}
|
|
sqlite.exec(`
|
|
INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order)
|
|
VALUES
|
|
('ws_default_inbox', 'ws_default', NULL, '等待分类', 'inbox', 'notebook', '#0f766e', 10),
|
|
('nb_under_duplicate', 'ws_default', 'ws_default_inbox', 'Nested', 'nested', 'notebook', '#0f766e', 20);
|
|
INSERT INTO memos (id, workspace_id, notebook_id, title, excerpt)
|
|
VALUES
|
|
('memo_on_original', 'ws_default', 'nb_inbox', 'Keep me', 'keep'),
|
|
('memo_on_duplicate', 'ws_default', 'ws_default_inbox', 'Move me', 'move');
|
|
`);
|
|
sqlite.exec(readFileSync("migrations/0053_collapse_duplicate_inbox_notebooks.sql", "utf8"));
|
|
|
|
expect(
|
|
sqlite.query(`
|
|
SELECT id FROM notebooks
|
|
WHERE workspace_id = 'ws_default' AND is_deleted = 0
|
|
AND (slug = 'inbox' OR id = 'nb_inbox' OR id = workspace_id || '_inbox')
|
|
`).all(),
|
|
).toEqual([{ id: "nb_inbox" }]);
|
|
expect(
|
|
sqlite.query("SELECT notebook_id FROM memos WHERE id IN ('memo_on_duplicate', 'memo_on_original') ORDER BY id").all(),
|
|
).toEqual([
|
|
{ notebook_id: "nb_inbox" },
|
|
{ notebook_id: "nb_inbox" },
|
|
]);
|
|
expect(sqlite.query("SELECT parent_id, is_deleted FROM notebooks WHERE id = 'nb_under_duplicate'").get()).toEqual({
|
|
parent_id: "nb_inbox",
|
|
is_deleted: 0,
|
|
});
|
|
expect(sqlite.query("SELECT is_deleted FROM notebooks WHERE id = 'ws_default_inbox'").get()).toEqual({
|
|
is_deleted: 1,
|
|
});
|
|
expect(sqlite.query("PRAGMA foreign_key_check").all()).toEqual([]);
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
});
|
|
|
|
test("rejects deleting a workspace inbox after it was renamed", async () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
await updateNotebookRecord(fixture.db, "ws_1", "ws_1_inbox", { name: "收集箱" }, actor);
|
|
await expect(deleteNotebookRecord(fixture.db, "ws_1", "ws_1_inbox", actor)).rejects.toMatchObject({
|
|
code: "bad_request",
|
|
});
|
|
expect(
|
|
fixture.sqlite.query("SELECT is_deleted, slug FROM notebooks WHERE id = 'ws_1_inbox'").get(),
|
|
).toEqual({ is_deleted: 0, slug: "inbox" });
|
|
} finally {
|
|
fixture.sqlite.close();
|
|
}
|
|
});
|
|
|
|
test("deletes an empty notebook together with its empty descendants", async () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
fixture.sqlite.exec(`
|
|
UPDATE notebooks SET name = '注册考试', slug = NULL WHERE id = 'nb_notes';
|
|
INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, is_deleted, created_at, updated_at) VALUES
|
|
('nb_law', 'ws_1', 'nb_notes', '法律法规', NULL, 0, '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z'),
|
|
('nb_history', 'ws_1', 'nb_notes', '建筑史', 'history', 0, '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z'),
|
|
('nb_outline', 'ws_1', 'nb_history', '大纲', 'outline', 0, '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z');
|
|
INSERT INTO memos (id, workspace_id, notebook_id, title, is_deleted, updated_at)
|
|
VALUES ('memo_trashed', 'ws_1', 'nb_law', '已删除', 1, '2026-09-10T00:00:00.000Z');
|
|
`);
|
|
|
|
await deleteNotebookRecord(fixture.db, "ws_1", "nb_notes", actor);
|
|
|
|
expect(
|
|
fixture.sqlite.query("SELECT id FROM notebooks WHERE workspace_id = 'ws_1' AND is_deleted = 0 ORDER BY id").all(),
|
|
).toEqual([{ id: "ws_1_inbox" }]);
|
|
await expect(deleteNotebookRecord(fixture.db, "ws_1", "nb_notes", actor)).resolves.toBeUndefined();
|
|
} finally {
|
|
fixture.sqlite.close();
|
|
}
|
|
});
|
|
|
|
test("keeps the whole notebook tree when any descendant still has a note", async () => {
|
|
const fixture = createFixture();
|
|
try {
|
|
fixture.sqlite.exec(`
|
|
INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, is_deleted, created_at, updated_at) VALUES
|
|
('nb_child', 'ws_1', 'nb_notes', '子笔记本', 'child', 0, '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z');
|
|
INSERT INTO memos (id, workspace_id, notebook_id, title, is_deleted, updated_at)
|
|
VALUES ('memo_keep', 'ws_1', 'nb_child', '还在', 0, '2026-09-10T00:00:00.000Z');
|
|
`);
|
|
|
|
await expect(deleteNotebookRecord(fixture.db, "ws_1", "nb_notes", actor)).rejects.toMatchObject({
|
|
code: "notebook_not_empty",
|
|
status: 409,
|
|
});
|
|
expect(
|
|
fixture.sqlite.query("SELECT id FROM notebooks WHERE workspace_id = 'ws_1' AND is_deleted = 1").all(),
|
|
).toEqual([]);
|
|
} finally {
|
|
fixture.sqlite.close();
|
|
}
|
|
});
|
|
});
|