Files
edgeever/apps/api/src/sync-routes.test.mjs
T
tianma-if cc874cd3f9 fix(sync): keep desktop pull working without a slug inbox
Identify the workspace inbox by stable id, restore it when missing, and apply notebooks before memos so a renamed or deleted inbox cannot abort the whole pull.

Fixes #378
2026-09-10 11:52:01 +08:00

413 lines
16 KiB
JavaScript

import { describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { Hono } from "hono";
import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts";
import { registerSyncRoutes } from "./sync-routes.ts";
const agentAuth = {
kind: "agent",
actorType: "agent",
actorId: "tok_sync",
username: "sync",
displayName: null,
scopes: ["read:notebooks", "read:memos"],
workspaceId: "ws_1",
role: "member",
};
const createApp = (auth = agentAuth) => {
const app = new Hono();
app.use("/api/v1/*", async (context, next) => {
context.set("auth", auth);
await next();
});
registerSyncRoutes(app, {
clampNumber: (value, min, max) => Math.min(Math.max(value, min), max),
mapMemoDetail: (row) => ({ id: row.id, title: row.title }),
});
return app;
};
const createSyncIntegrationFixture = () => {
const sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE workspaces (id TEXT PRIMARY KEY, created_at TEXT NOT NULL);
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
);
CREATE TABLE memos (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, notebook_id TEXT NOT NULL, title TEXT,
excerpt TEXT NOT NULL DEFAULT '', tags_json TEXT NOT NULL DEFAULT '[]',
is_pinned INTEGER NOT NULL DEFAULT 0, is_archived INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0, source_memo_ids TEXT NOT NULL DEFAULT '[]',
merge_source_count INTEGER NOT NULL DEFAULT 0, merged_into_memo_id TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT
);
CREATE TABLE memo_contents (
memo_id TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0, content_json TEXT NOT NULL,
content_markdown TEXT NOT NULL DEFAULT '', content_text TEXT NOT NULL DEFAULT '', content_hash TEXT NOT NULL
);
CREATE TABLE mobile_sync_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL, entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL, operation TEXT NOT NULL
);
INSERT INTO workspaces VALUES ('ws_1', '2026-09-09T00:00:00.000Z');
INSERT INTO notebooks (id, workspace_id, name, slug, is_deleted, created_at, updated_at) VALUES
('nb_inbox', 'ws_1', 'Inbox', 'inbox', 0, '2026-09-09T00:00:00.000Z', '2026-09-09T00:00:00.000Z'),
('nb_deleted', 'ws_1', 'Deleted', 'deleted', 1, '2026-09-09T00:00:00.000Z', '2026-09-09T00:00:00.000Z'),
('nb_child', 'ws_1', 'Recovered child', 'child', 0, '2026-09-09T00:00:00.000Z', '2026-09-09T00:00:00.000Z');
UPDATE notebooks SET parent_id = 'nb_deleted' WHERE id = 'nb_child';
INSERT INTO memos (
id, workspace_id, notebook_id, title, is_deleted, created_at, updated_at, deleted_at
) VALUES (
'memo_trashed', 'ws_1', 'nb_deleted', 'Trashed', 1,
'2026-09-09T00:00:00.000Z', '2026-09-09T00:00:00.000Z', '2026-09-09T00:00:00.000Z'
);
INSERT INTO memo_contents VALUES ('memo_trashed', 1, '{"type":"doc"}', '', '', 'hash');
INSERT INTO mobile_sync_changes (workspace_id, entity_type, entity_id, operation) VALUES
('ws_1', 'memo', 'memo_trashed', 'upsert'),
('ws_1', 'notebook', 'nb_child', 'upsert');
`);
const storage = createSelfHostedStorageAdapter(sqlite, "/tmp/edgeever-sync-route-test-unused");
const app = new Hono();
app.use("/api/v1/*", async (context, next) => {
context.set("auth", agentAuth);
await next();
});
registerSyncRoutes(app, {
clampNumber: (value, min, max) => Math.min(Math.max(value, min), max),
mapMemoDetail: (row) => ({
id: row.id,
notebookId: row.notebook_id,
title: row.title,
isDeleted: row.is_deleted === 1,
}),
});
return { app, sqlite, storage };
};
describe("sync route contracts", () => {
test("keeps bootstrap references inside the active notebook snapshot", async () => {
const fixture = createSyncIntegrationFixture();
try {
const response = await fixture.app.request(
"/api/v1/sync/bootstrap?limit=200",
{},
{ storage: fixture.storage },
);
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.notebooks.map((notebook) => notebook.id)).toEqual(["nb_inbox", "nb_child"]);
expect(payload.notebooks.find((notebook) => notebook.id === "nb_child")?.parentId).toBeNull();
expect(payload.memos).toEqual([{
id: "memo_trashed",
notebookId: "nb_inbox",
title: "Trashed",
isDeleted: true,
}]);
} finally {
fixture.sqlite.close();
}
});
test("remaps dangling memos onto a renamed workspace inbox", async () => {
const sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE workspaces (id TEXT PRIMARY KEY, created_at TEXT NOT NULL);
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
);
CREATE TABLE memos (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, notebook_id TEXT NOT NULL, title TEXT,
excerpt TEXT NOT NULL DEFAULT '', tags_json TEXT NOT NULL DEFAULT '[]',
is_pinned INTEGER NOT NULL DEFAULT 0, is_archived INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0, source_memo_ids TEXT NOT NULL DEFAULT '[]',
merge_source_count INTEGER NOT NULL DEFAULT 0, merged_into_memo_id TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT
);
CREATE TABLE memo_contents (
memo_id TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0, content_json TEXT NOT NULL,
content_markdown TEXT NOT NULL DEFAULT '', content_text TEXT NOT NULL DEFAULT '', content_hash TEXT NOT NULL
);
CREATE TABLE mobile_sync_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL, entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL, operation TEXT NOT NULL
);
INSERT INTO workspaces VALUES ('ws_1', '2026-09-10T00:00:00.000Z');
INSERT INTO notebooks (id, workspace_id, name, slug, is_deleted, created_at, updated_at) VALUES
('ws_1_inbox', 'ws_1', '收集箱', 'shou-ji-xiang', 0, '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z');
INSERT INTO memos (
id, workspace_id, notebook_id, title, is_deleted, created_at, updated_at, deleted_at
) VALUES (
'memo_trashed', 'ws_1', 'nb_deleted', 'Trashed', 1,
'2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z'
);
INSERT INTO memo_contents VALUES ('memo_trashed', 1, '{"type":"doc"}', '', '', 'hash');
`);
const storage = createSelfHostedStorageAdapter(sqlite, "/tmp/edgeever-sync-renamed-inbox-unused");
const app = new Hono();
app.use("/api/v1/*", async (context, next) => {
context.set("auth", agentAuth);
await next();
});
registerSyncRoutes(app, {
clampNumber: (value, min, max) => Math.min(Math.max(value, min), max),
mapMemoDetail: (row) => ({
id: row.id,
notebookId: row.notebook_id,
title: row.title,
isDeleted: row.is_deleted === 1,
}),
});
try {
const response = await app.request("/api/v1/sync/bootstrap?limit=200", {}, { storage });
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.memos[0].notebookId).toBe("ws_1_inbox");
} finally {
sqlite.close();
}
});
test("normalizes dangling references in incremental changes", async () => {
const fixture = createSyncIntegrationFixture();
try {
const response = await fixture.app.request(
"/api/v1/sync/changes?cursor=0&limit=200",
{},
{ storage: fixture.storage },
);
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.changes[0].memo.notebookId).toBe("nb_inbox");
expect(payload.changes[1].notebook.parentId).toBeNull();
} finally {
fixture.sqlite.close();
}
});
test("requires both notebook and memo read scopes", async () => {
const app = createApp({ ...agentAuth, scopes: ["read:memos"] });
const response = await app.request("/api/v1/sync/bootstrap", {}, {
storage: {
db: { prepare: () => { throw new Error("Unexpected database access"); } },
resources: {},
},
});
expect(response.status).toBe(403);
expect(await response.json()).toMatchObject({ error: { code: "forbidden" } });
});
test("preserves bootstrap pagination and snapshot metadata", async () => {
const memoRows = [
{ id: "memo_1", title: "First" },
{ id: "memo_2", title: "Second" },
];
const database = {
prepare: (sql) => ({
bind: () => ({
all: async () => {
if (sql.includes("FROM memos m")) return { results: memoRows };
if (sql.includes("FROM notebooks n")) return { results: [] };
return { results: [] };
},
first: async () => {
if (sql.includes("COUNT(*)")) return { count: 2 };
if (sql.includes("sync_identity")) {
return { cursor: 42, sync_identity: "workspace-created-at" };
}
return null;
},
}),
}),
};
const response = await createApp().request(
"/api/v1/sync/bootstrap?limit=1",
{},
{ storage: { db: database, resources: {} } },
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
notebooks: [],
memos: [{ id: "memo_1", title: "First" }],
snapshotCursor: 42,
syncIdentity: "workspace-created-at",
totalCount: 2,
nextAfterId: "memo_1",
});
});
test("loads changes and workspace cursor state in one indexed query", async () => {
const sqlStatements = [];
const database = {
prepare: (sql) => {
sqlStatements.push(sql);
return {
bind: () => ({
all: async () => ({ results: [{
id: null,
entity_type: null,
entity_id: null,
operation: null,
server_cursor: 0,
sync_identity: "workspace-created-at",
}] }),
first: async () => null,
}),
};
},
};
const response = await createApp().request(
"/api/v1/sync/changes?cursor=0&limit=200",
{},
{ storage: { db: database, resources: {} } },
);
expect(response.status).toBe(200);
expect(sqlStatements).toHaveLength(1);
const cursorSql = sqlStatements[0];
expect(cursorSql).toContain("WHERE c.workspace_id = w.id");
expect(cursorSql).toContain("WHERE workspace_id = ? AND id > ?");
});
test("folds repeated entities within a page while preserving the raw page cursor", async () => {
const database = {
prepare: (sql) => ({
bind: () => ({
all: async () => {
if (sql.includes("WITH workspace_state")) {
return {
results: [
{ id: 11, entity_type: "memo", entity_id: "memo_1", operation: "upsert", server_cursor: 13, sync_identity: "workspace-created-at" },
{ id: 12, entity_type: "memo", entity_id: "memo_1", operation: "upsert", server_cursor: 13, sync_identity: "workspace-created-at" },
{ id: 13, entity_type: "notebook", entity_id: "notebook_1", operation: "delete", server_cursor: 13, sync_identity: "workspace-created-at" },
],
};
}
if (sql.includes("FROM memos m")) return { results: [{ id: "memo_1", title: "Latest" }] };
return { results: [] };
},
first: async () => null,
}),
}),
};
const response = await createApp().request(
"/api/v1/sync/changes?cursor=10&limit=200",
{},
{ storage: { db: database, resources: {} } },
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
changes: [
{
cursor: 12,
entityType: "memo",
entityId: "memo_1",
operation: "upsert",
notebook: null,
memo: { id: "memo_1", title: "Latest" },
},
{
cursor: 13,
entityType: "notebook",
entityId: "notebook_1",
operation: "delete",
notebook: null,
memo: null,
},
],
cursor: 13,
hasMore: false,
serverCursor: 13,
syncIdentity: "workspace-created-at",
});
});
test("does not fold changes across a raw page boundary", async () => {
const database = {
prepare: (sql) => ({
bind: () => ({
all: async () => {
if (sql.includes("WITH workspace_state")) {
return {
results: [
{ id: 21, entity_type: "memo", entity_id: "memo_1", operation: "delete", server_cursor: 22, sync_identity: "workspace-created-at" },
{ id: 22, entity_type: "memo", entity_id: "memo_1", operation: "upsert", server_cursor: 22, sync_identity: "workspace-created-at" },
],
};
}
return { results: [] };
},
first: async () => null,
}),
}),
};
const response = await createApp().request(
"/api/v1/sync/changes?cursor=20&limit=1",
{},
{ storage: { db: database, resources: {} } },
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
changes: [{ cursor: 21, operation: "delete" }],
cursor: 21,
hasMore: true,
serverCursor: 22,
});
});
test("batches detail queries below Cloudflare D1's bound-parameter limit", async () => {
const changes = Array.from({ length: 200 }, (_, index) => ({
id: index + 1,
entity_type: "memo",
entity_id: `memo_${String(index + 1).padStart(3, "0")}`,
operation: "upsert",
server_cursor: 200,
sync_identity: "workspace-created-at",
}));
const detailQueryBindingCounts = [];
const database = {
prepare: (sql) => ({
bind: (...bindings) => {
if (bindings.length > 100) {
throw new Error(`D1_ERROR: too many SQL variables (${bindings.length})`);
}
return {
all: async () => {
if (sql.includes("WITH workspace_state")) return { results: changes };
if (sql.includes("FROM memos m")) {
detailQueryBindingCounts.push(bindings.length);
return {
results: bindings.slice(1).map((id) => ({ id, title: `Title ${id}` })),
};
}
return { results: [] };
},
first: async () => null,
};
},
}),
};
const response = await createApp().request(
"/api/v1/sync/changes?cursor=0&limit=200",
{},
{ storage: { db: database, resources: {} } },
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.changes).toHaveLength(200);
expect(body.changes[0]).toMatchObject({ entityId: "memo_001", operation: "upsert" });
expect(body.changes.at(-1)).toMatchObject({ entityId: "memo_200", operation: "upsert" });
expect(detailQueryBindingCounts).toEqual([91, 91, 21]);
});
});