Sidecar memo.list ignored the tag parameter, so choosing a tag showed every note. Match json_each tag values case-insensitively and keep prefix-overlapping names out of the result.
416 lines
26 KiB
JavaScript
416 lines
26 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
import { createInterface } from "node:readline";
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
const sidecarPath = process.env.EDGE_EVER_SIDECAR_PATH ?? join(process.cwd(), "crates/desktop-sidecar/target/debug/edgeever-sidecar");
|
|
const migrationsPath = process.env.EDGE_EVER_MIGRATIONS_PATH ?? join(process.cwd(), "migrations");
|
|
const dataDir = mkdtempSync(join(tmpdir(), "edgeever-sidecar-test-"));
|
|
const child = spawn(sidecarPath, ["--data-dir", dataDir, "--migrations-dir", migrationsPath], { stdio: ["pipe", "pipe", "inherit"] });
|
|
const lines = createInterface({ input: child.stdout });
|
|
const messages = [];
|
|
lines.on("line", (line) => {
|
|
try { messages.push(JSON.parse(line)); } catch { /* Ignore diagnostics. */ }
|
|
});
|
|
|
|
const waitFor = async (predicate, timeoutMs = 10000) => {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const index = messages.findIndex(predicate);
|
|
if (index >= 0) return messages.splice(index, 1)[0];
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
}
|
|
throw new Error("Timed out waiting for sidecar response");
|
|
};
|
|
|
|
let nextId = 1;
|
|
const request = async (method, params = {}) => {
|
|
const id = nextId++;
|
|
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
|
|
const response = await waitFor((message) => message.id === id);
|
|
if (response.error) throw new Error(response.error.message);
|
|
return response.result;
|
|
};
|
|
|
|
await waitFor((message) => message.event === "ready");
|
|
const notebooks = await request("notebook.list");
|
|
const seedInbox = notebooks.notebooks.find((notebook) => notebook.slug === "inbox");
|
|
assert.ok(seedInbox, "seed inbox notebook should exist");
|
|
assert.deepEqual(await request("sync.bootstrap.prepare"), { clearedSeedData: true, rebuiltMirror: false });
|
|
assert.equal((await request("memo.list", { limit: 20 })).totalCount, 0, "bootstrap preparation should remove only pristine seed data");
|
|
assert.equal((await request("notebook.list")).notebooks.length, 0, "bootstrap preparation should remove pristine seed notebooks");
|
|
const inbox = (await request("notebook.create", { name: "Inbox" })).notebook;
|
|
if (process.platform !== "win32") {
|
|
assert.equal(statSync(dataDir).mode & 0o777, 0o700, "sidecar data directory should be private");
|
|
assert.equal(statSync(join(dataDir, "edgeever.sqlite")).mode & 0o777, 0o600, "sidecar database should be private");
|
|
}
|
|
|
|
const remoteMemo = (id, {
|
|
mergedIntoMemoId = null,
|
|
sourceMemoIds = [],
|
|
notebookId = inbox.id,
|
|
isDeleted = Boolean(mergedIntoMemoId),
|
|
} = {}) => ({
|
|
id,
|
|
notebookId,
|
|
title: id,
|
|
excerpt: "",
|
|
tags: [],
|
|
isPinned: false,
|
|
isArchived: false,
|
|
isDeleted,
|
|
revision: 0,
|
|
createdAt: "2026-09-06T00:00:00.000Z",
|
|
updatedAt: "2026-09-06T00:00:00.000Z",
|
|
deletedAt: isDeleted ? "2026-09-06T00:00:00.000Z" : null,
|
|
contentJson: { type: "doc", content: [] },
|
|
contentMarkdown: "",
|
|
contentText: "",
|
|
contentHash: id,
|
|
sourceMemoIds,
|
|
mergeSourceCount: sourceMemoIds.length,
|
|
mergedIntoMemoId,
|
|
});
|
|
const applyRemoteMemo = (memo) => request("sync.apply", {
|
|
changes: [{ entityType: "memo", operation: "upsert", entityId: memo.id, memo, notebook: null }],
|
|
});
|
|
|
|
// Regression for #362: bootstrap memo pages are sorted by id, so a deleted
|
|
// merge source can reach the real sidecar process before its merge target.
|
|
await applyRemoteMemo(remoteMemo("memo_e2e_merge_source_before", { mergedIntoMemoId: "memo_e2e_merge_target" }));
|
|
assert.equal(
|
|
(await request("memo.get", { memoId: "memo_e2e_merge_source_before", includeDeleted: true })).memo.mergedIntoMemoId,
|
|
null,
|
|
"an unresolved merge target must not fail the bootstrap page",
|
|
);
|
|
await applyRemoteMemo(remoteMemo("memo_e2e_merge_target", {
|
|
sourceMemoIds: ["memo_e2e_merge_source_before", "memo_e2e_merge_source_after"],
|
|
}));
|
|
await applyRemoteMemo(remoteMemo("memo_e2e_merge_source_after", { mergedIntoMemoId: "memo_e2e_merge_target" }));
|
|
for (const sourceId of ["memo_e2e_merge_source_before", "memo_e2e_merge_source_after"]) {
|
|
assert.equal(
|
|
(await request("memo.get", { memoId: sourceId, includeDeleted: true })).memo.mergedIntoMemoId,
|
|
"memo_e2e_merge_target",
|
|
"merge links should converge regardless of bootstrap page order",
|
|
);
|
|
}
|
|
assert.equal((await request("memo.emptyTrash")).deleted, 2, "merge-order fixtures should not leak into later scenarios");
|
|
|
|
// Regression for #371: older servers can return a trashed memo whose original
|
|
// notebook is no longer part of the active notebook snapshot.
|
|
await applyRemoteMemo(remoteMemo("memo_e2e_deleted_notebook", {
|
|
notebookId: "notebook_deleted_on_server",
|
|
isDeleted: true,
|
|
}));
|
|
assert.equal(
|
|
(await request("memo.get", { memoId: "memo_e2e_deleted_notebook", includeDeleted: true })).memo.notebookId,
|
|
inbox.id,
|
|
"a missing remote notebook should fall back to the local inbox",
|
|
);
|
|
assert.equal((await request("memo.emptyTrash")).deleted, 1, "deleted-notebook fixture should not leak into later scenarios");
|
|
|
|
const first = await request("memo.create", { notebookId: inbox.id, title: "Local first", contentMarkdown: "searchable body", tags: ["local"] });
|
|
assert.deepEqual(await request("sync.bootstrap.prepare"), { clearedSeedData: false, rebuiltMirror: false });
|
|
assert.equal((await request("memo.get", { memoId: first.memo.id })).memo.id, first.memo.id, "bootstrap preparation must preserve local user data");
|
|
const second = await request("memo.create", { notebookId: inbox.id, title: "Second memo", contentMarkdown: "another body", tags: [] });
|
|
const search = await request("memo.list", { q: "searchable", limit: 20 });
|
|
assert.deepEqual(search.memos.map((memo) => memo.id), [first.memo.id]);
|
|
await request("memo.create", { notebookId: inbox.id, title: "Local daily", contentMarkdown: "prefix overlap", tags: ["local-daily"] });
|
|
const tagged = await request("memo.list", { tag: "local", limit: 20 });
|
|
assert.deepEqual(tagged.memos.map((memo) => memo.id), [first.memo.id], "tag filter should match an exact tag, not a prefix");
|
|
assert.equal(tagged.totalCount, 1, "tag filter total should count only exact matches");
|
|
const taggedCase = await request("memo.list", { tag: "LOCAL", limit: 20 });
|
|
assert.deepEqual(taggedCase.memos.map((memo) => memo.id), [first.memo.id], "tag filter should match tags case-insensitively");
|
|
const missingTag = await request("memo.list", { tag: "missing-tag", limit: 20 });
|
|
assert.equal(missingTag.totalCount, 0, "unknown tags should not leak untagged notes into the list");
|
|
const childNotebook = (await request("notebook.create", { name: "Inbox child", parentId: inbox.id })).notebook;
|
|
const childMemo = await request("memo.create", { notebookId: childNotebook.id, title: "Nested memo", contentMarkdown: "nested body", tags: [] });
|
|
const subtree = await request("memo.list", {
|
|
notebookId: inbox.id,
|
|
notebookIds: [inbox.id, childNotebook.id],
|
|
limit: 20,
|
|
});
|
|
assert.ok(subtree.memos.some((memo) => memo.id === first.memo.id), "parent notebook notes should remain in a subtree query");
|
|
assert.ok(subtree.memos.some((memo) => memo.id === childMemo.memo.id), "subtree queries should include child notebook notes");
|
|
await request("memo.update", {
|
|
memoId: first.memo.id,
|
|
title: "Local first updated",
|
|
contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "updated body" }] }] },
|
|
contentMarkdown: "updated body",
|
|
contentText: "updated body",
|
|
tags: ["updated"],
|
|
});
|
|
const revisions = await request("memo.revisions", { memoId: first.memo.id });
|
|
assert.ok(revisions.revisions.length >= 1, "memo update should create a revision");
|
|
await request("memo.restoreRevision", { memoId: first.memo.id, revisionId: revisions.revisions[0].id });
|
|
const restoredRevisionMemo = await request("memo.get", { memoId: first.memo.id });
|
|
assert.equal(restoredRevisionMemo.memo.contentMarkdown, "searchable body");
|
|
await request("memo.revision.cache", {
|
|
revision: {
|
|
id: "remote_revision_test",
|
|
memoId: first.memo.id,
|
|
revision: 99,
|
|
title: "Remote cached revision",
|
|
tags: ["remote"],
|
|
contentJson: { type: "doc", content: [] },
|
|
contentMarkdown: "cached remote revision",
|
|
contentText: "cached remote revision",
|
|
contentHash: "remote-hash",
|
|
createdBy: "remote",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
});
|
|
const cachedRevisions = await request("memo.revisions", { memoId: first.memo.id });
|
|
assert.ok(cachedRevisions.revisions.some((revision) => revision.id === "remote_revision_test"));
|
|
|
|
const coalesced = await request("memo.create", { notebookId: inbox.id, title: "Autosave coalesce", contentMarkdown: "initial", tags: [] });
|
|
const updatePayload = (contentMarkdown) => ({
|
|
memoId: coalesced.memo.id,
|
|
title: "Autosave coalesce",
|
|
contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: contentMarkdown }] }] },
|
|
contentMarkdown,
|
|
contentText: contentMarkdown,
|
|
tags: [],
|
|
});
|
|
await request("memo.update", updatePayload("first autosave"));
|
|
await request("memo.update", updatePayload("latest autosave"));
|
|
const coalescedUpdates = (await request("sync.outbox.list", { limit: 200 })).items.filter((item) => item.kind === "memo.update" && item.entityId === coalesced.memo.id);
|
|
assert.equal(coalescedUpdates.length, 1, "offline autosaves should coalesce into one sidecar outbox item");
|
|
assert.equal(coalescedUpdates[0].payload.contentMarkdown, "latest autosave");
|
|
assert.equal((await request("memo.get", { memoId: coalesced.memo.id })).memo.revision, 0, "local autosaves must not advance the acknowledged cloud revision");
|
|
assert.equal(coalescedUpdates[0].version, 2, "coalescing a newer autosave should advance the outbox version");
|
|
|
|
const remapRace = await request("memo.create", { notebookId: inbox.id, title: "Remap race", contentMarkdown: "created locally", tags: [] });
|
|
const remapCreate = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.kind === "memo.create" && item.entityId === remapRace.memo.id);
|
|
assert.ok(remapCreate, "new desktop memo should have a create outbox item");
|
|
const remoteCreatedMemo = {
|
|
...remapRace.memo,
|
|
id: "memo_remote_remap_race",
|
|
revision: 1,
|
|
updatedAt: "2026-08-26T02:15:21.000Z",
|
|
};
|
|
await request("sync.outbox.ack", { id: remapCreate.id, version: remapCreate.version, remoteMemo: remoteCreatedMemo });
|
|
await request("memo.update", {
|
|
...updatePayload("first save after create acknowledgement"),
|
|
memoId: remoteCreatedMemo.id,
|
|
expectedRevision: 0,
|
|
expectedContentHash: remapRace.memo.contentHash,
|
|
});
|
|
const remappedUpdate = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.kind === "memo.update" && item.entityId === remoteCreatedMemo.id);
|
|
assert.ok(remappedUpdate, "editing immediately after id remapping should queue a remote-id update");
|
|
assert.equal(remappedUpdate.payload.expectedRevision, remoteCreatedMemo.revision, "a scheduled revision-0 autosave should rebase to its own create acknowledgement");
|
|
assert.equal(remappedUpdate.payload.expectedContentHash, remoteCreatedMemo.contentHash);
|
|
|
|
await request("memo.update", {
|
|
...updatePayload("successor typed while the first request was in flight"),
|
|
memoId: remoteCreatedMemo.id,
|
|
expectedRevision: remoteCreatedMemo.revision,
|
|
expectedContentHash: remoteCreatedMemo.contentHash,
|
|
});
|
|
const successorBeforeAck = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.id === remappedUpdate.id);
|
|
assert.equal(successorBeforeAck.version, remappedUpdate.version + 1);
|
|
const firstRemoteUpdate = {
|
|
...remoteCreatedMemo,
|
|
revision: 2,
|
|
contentJson: remappedUpdate.payload.contentJson,
|
|
contentMarkdown: remappedUpdate.payload.contentMarkdown,
|
|
contentText: remappedUpdate.payload.contentMarkdown,
|
|
contentHash: "remote-hash-after-first-save",
|
|
updatedAt: "2026-08-26T02:15:22.000Z",
|
|
};
|
|
const supersededAck = await request("sync.outbox.ack", {
|
|
id: remappedUpdate.id,
|
|
version: remappedUpdate.version,
|
|
remoteMemo: firstRemoteUpdate,
|
|
});
|
|
assert.equal(supersededAck.superseded, true, "an acknowledgement must not delete a newer coalesced autosave");
|
|
const successorAfterAck = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.id === remappedUpdate.id);
|
|
assert.ok(successorAfterAck, "the successor autosave should remain queued");
|
|
assert.equal(successorAfterAck.status, "pending");
|
|
assert.equal(successorAfterAck.payload.contentMarkdown, "successor typed while the first request was in flight");
|
|
assert.equal(successorAfterAck.payload.expectedRevision, firstRemoteUpdate.revision);
|
|
assert.equal(successorAfterAck.payload.expectedContentHash, firstRemoteUpdate.contentHash);
|
|
const preservedSuccessor = await request("memo.get", { memoId: remoteCreatedMemo.id });
|
|
assert.equal(preservedSuccessor.memo.contentMarkdown, "successor typed while the first request was in flight", "acknowledging an older request must preserve the live local draft");
|
|
assert.equal(preservedSuccessor.memo.revision, firstRemoteUpdate.revision, "the preserved successor should advance to the acknowledged cloud revision");
|
|
const secondRemoteUpdate = {
|
|
...preservedSuccessor.memo,
|
|
revision: 3,
|
|
contentHash: "remote-hash-after-successor",
|
|
updatedAt: "2026-08-26T02:15:23.000Z",
|
|
};
|
|
const finalAck = await request("sync.outbox.ack", {
|
|
id: successorAfterAck.id,
|
|
version: successorAfterAck.version,
|
|
remoteMemo: secondRemoteUpdate,
|
|
});
|
|
assert.equal(finalAck.superseded, false);
|
|
assert.ok(!(await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.id === successorAfterAck.id), "the exact successor acknowledgement should clear the outbox item");
|
|
assert.equal((await request("memo.get", { memoId: remoteCreatedMemo.id })).memo.revision, secondRemoteUpdate.revision);
|
|
|
|
const batchNotebook = (await request("notebook.create", { name: "Batch destination" })).notebook;
|
|
const batchFirst = (await request("memo.create", { notebookId: inbox.id, title: "Batch one", contentMarkdown: "batch one", tags: ["batch-tag"] })).memo;
|
|
const batchSecond = (await request("memo.create", { notebookId: inbox.id, title: "Batch two", contentMarkdown: "batch two", tags: ["batch-tag"] })).memo;
|
|
const renamedTag = await request("tag.rename", { tag: "batch-tag", name: "renamed-batch-tag" });
|
|
assert.equal(renamedTag.updated, 2);
|
|
assert.ok((await request("tag.list")).tags.some((tag) => tag.name === "renamed-batch-tag"));
|
|
assert.equal((await request("memo.moveBatch", { memoIds: [batchFirst.id, batchSecond.id], notebookId: batchNotebook.id })).moved, 2);
|
|
assert.equal((await request("memo.pinBatch", { memoIds: [batchFirst.id, batchSecond.id], isPinned: true })).updated, 2);
|
|
assert.equal((await request("memo.list", { notebookId: batchNotebook.id, filter: "pinned", limit: 20 })).totalCount, 2);
|
|
assert.equal((await request("memo.deleteBatch", { memoIds: [batchFirst.id, batchSecond.id] })).deleted, 2);
|
|
assert.equal((await request("memo.list", { trash: true, notebookId: batchNotebook.id, limit: 20 })).totalCount, 2);
|
|
await request("memo.restore", { memoId: batchFirst.id });
|
|
assert.equal((await request("memo.list", { notebookId: batchNotebook.id, limit: 20 })).totalCount, 1);
|
|
assert.equal((await request("memo.emptyTrash")).deleted, 1);
|
|
assert.equal((await request("memo.list", { trash: true, notebookId: batchNotebook.id, limit: 20 })).totalCount, 0);
|
|
|
|
await request("resource.cache", {
|
|
resource: {
|
|
id: "resource-from-merge-source",
|
|
memoId: first.memo.id,
|
|
originalMemoId: null,
|
|
kind: "attachment",
|
|
mimeType: "application/zip",
|
|
filename: "merged-source.zip",
|
|
byteSize: 42,
|
|
sha256: "resource-hash",
|
|
width: null,
|
|
height: null,
|
|
},
|
|
});
|
|
const merged = await request("memo.merge", { memoIds: [first.memo.id, second.memo.id] });
|
|
assert.equal(merged.memo.sourceMemoIds.length, 2);
|
|
const trash = await request("memo.list", { trash: true, limit: 20 });
|
|
assert.ok(trash.memos.some((memo) => memo.id === first.memo.id));
|
|
const mergeOutbox = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.kind === "memo.merge" && item.entityId === merged.memo.id);
|
|
assert.ok(mergeOutbox, "memo merge should be queued");
|
|
const remoteMergedMemo = { ...merged.memo, id: "memo_remote_merged" };
|
|
await request("sync.apply", {
|
|
changes: [{ entityType: "memo", operation: "upsert", entityId: remoteMergedMemo.id, memo: remoteMergedMemo, notebook: null }],
|
|
});
|
|
await request("sync.outbox.ack", { id: mergeOutbox.id, remoteMemo: remoteMergedMemo });
|
|
await assert.rejects(() => request("memo.get", { memoId: merged.memo.id, includeDeleted: true }), /Query returned no rows/);
|
|
assert.equal((await request("memo.get", { memoId: first.memo.id, includeDeleted: true })).memo.mergedIntoMemoId, remoteMergedMemo.id);
|
|
const mergedResource = (await request("resource.list", { limit: 200 })).resources.find((resource) => resource.id === "resource-from-merge-source");
|
|
assert.equal(mergedResource.memoId, remoteMergedMemo.id, "merge acknowledgement should remap resources before deleting the local memo");
|
|
assert.equal(mergedResource.originalMemoId, first.memo.id, "merge acknowledgement should preserve the resource's source memo");
|
|
assert.ok(!(await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.id === mergeOutbox.id), "merge acknowledgement should clear the outbox item");
|
|
|
|
await request("template.cache", {
|
|
template: {
|
|
id: "tpl_remote_test",
|
|
name: "Remote test template",
|
|
description: null,
|
|
title: "Template title",
|
|
contentJson: { type: "doc", content: [] },
|
|
contentMarkdown: "Template body",
|
|
tags: ["test"],
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
});
|
|
const templates = await request("template.list");
|
|
assert.ok(templates.templates.some((template) => template.id === "tpl_remote_test"));
|
|
const localTemplate = await request("template.create", { name: "Local template", memoId: first.memo.id });
|
|
const templateOutbox = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.entityId === localTemplate.template.id);
|
|
assert.ok(templateOutbox, "template create should be queued");
|
|
assert.equal(templateOutbox.payload.contentMarkdown, "searchable body");
|
|
assert.deepEqual(templateOutbox.payload.tags, ["local"]);
|
|
await request("template.update", { templateId: localTemplate.template.id, name: "Local template updated", contentMarkdown: "updated template" });
|
|
const templateUpdateBeforeAck = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.kind === "template.update" && item.entityId === localTemplate.template.id);
|
|
assert.ok(templateUpdateBeforeAck, "template update should be queued behind create");
|
|
await request("sync.outbox.ack", {
|
|
id: templateOutbox.id,
|
|
remoteTemplate: {
|
|
id: "tpl_remote_mapped",
|
|
name: "Local template updated",
|
|
description: null,
|
|
title: "",
|
|
contentJson: { type: "doc", content: [] },
|
|
contentMarkdown: "updated template",
|
|
tags: [],
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
});
|
|
const templateUpdateAfterAck = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.kind === "template.update");
|
|
assert.equal(templateUpdateAfterAck.entityId, "tpl_remote_mapped");
|
|
assert.equal(templateUpdateAfterAck.payload.templateId, "tpl_remote_mapped");
|
|
await request("template.delete", { templateId: "tpl_remote_test" });
|
|
assert.equal((await request("template.list")).templates.some((template) => template.id === "tpl_remote_test"), false);
|
|
|
|
const backup = await request("storage.backup");
|
|
assert.equal(backup.ok, true);
|
|
const outsideBackup = join(dataDir, "outside.sqlite");
|
|
writeFileSync(outsideBackup, "not a managed backup");
|
|
await assert.rejects(() => request("storage.restore", { path: outsideBackup }), /managed EdgeEver backup/);
|
|
const stagedResourceDirectory = join(dataDir, "resource-outbox");
|
|
mkdirSync(stagedResourceDirectory, { recursive: true });
|
|
writeFileSync(join(stagedResourceDirectory, "stage-test.bin"), "offline attachment snapshot");
|
|
const backupWithResource = await request("storage.backup");
|
|
if (process.platform !== "win32") assert.equal(statSync(backupWithResource.path).mode & 0o777, 0o600, "database backups should be private");
|
|
const resourceBackupDirectory = backupWithResource.path.replace(/\.sqlite$/, ".resources");
|
|
assert.ok(existsSync(join(resourceBackupDirectory, "stage-test.bin")), "backup should include staged resources");
|
|
writeFileSync(join(stagedResourceDirectory, "stage-test.bin"), "changed after backup");
|
|
for (let index = 0; index < 4; index += 1) await request("storage.backup");
|
|
const backups = await request("storage.backups");
|
|
assert.ok(backups.backups.some((item) => item.path === backupWithResource.path));
|
|
const afterBackup = await request("memo.create", { notebookId: inbox.id, title: "After backup", contentMarkdown: "should disappear after restore", tags: [] });
|
|
await request("storage.restore", { path: backupWithResource.path });
|
|
await assert.rejects(() => request("memo.get", { memoId: afterBackup.memo.id }), /Query returned no rows/);
|
|
assert.equal(readFileSync(join(stagedResourceDirectory, "stage-test.bin"), "utf8"), "offline attachment snapshot", "restore should recover staged resources");
|
|
const outbox = await request("sync.outbox.list", { limit: 100 });
|
|
assert.ok(!outbox.items.some((item) => item.id === mergeOutbox.id), "a restored backup must not resurrect an acknowledged merge");
|
|
|
|
const deferredMemo = await request("memo.create", { notebookId: inbox.id, title: "Deferred retry", contentMarkdown: "retry later", tags: [] });
|
|
const deferredItem = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.entityId === deferredMemo.memo.id);
|
|
assert.ok(deferredItem, "deferred retry test should create an outbox item");
|
|
await request("sync.outbox.fail", {
|
|
id: deferredItem.id,
|
|
version: deferredItem.version,
|
|
error: "temporary outage",
|
|
errorCode: "http_503",
|
|
retryable: true,
|
|
nextAttemptAt: "2099-01-01T00:00:00.000Z",
|
|
});
|
|
assert.ok(!(await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.id === deferredItem.id), "a deferred error should wait until its retry time");
|
|
const deferredDetails = (await request("sync.outbox.list", { limit: 200, includeConflicts: true })).items.find((item) => item.id === deferredItem.id);
|
|
assert.equal(deferredDetails.lastErrorCode, "http_503");
|
|
assert.equal(deferredDetails.retryable, true);
|
|
assert.equal(deferredDetails.nextAttemptAt, "2099-01-01T00:00:00.000Z");
|
|
await request("sync.outbox.retry", { id: deferredItem.id, version: deferredItem.version });
|
|
assert.ok((await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.id === deferredItem.id), "manual retry should make a deferred item immediately eligible");
|
|
await request("sync.outbox.discard", { id: deferredItem.id });
|
|
|
|
const recoveryCandidate = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.kind === "memo.update" && item.entityId === coalesced.memo.id);
|
|
assert.ok(recoveryCandidate, "recovery test should reuse a durable memo update");
|
|
await request("sync.outbox.fail", {
|
|
id: recoveryCandidate.id,
|
|
version: recoveryCandidate.version,
|
|
error: "Memo not found",
|
|
errorCode: "memo_not_found",
|
|
retryable: false,
|
|
});
|
|
assert.ok(!(await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.id === recoveryCandidate.id), "a permanent missing memo error must stop automatic retries");
|
|
const recovered = await request("sync.outbox.recoverMemoUpdate", { id: recoveryCandidate.id, version: recoveryCandidate.version, notebookId: inbox.id });
|
|
assert.equal(recovered.memo.contentMarkdown, "latest autosave", "recovery should preserve the failed update content");
|
|
assert.deepEqual(recovered.memo.contentJson, recoveryCandidate.payload.contentJson, "recovery should preserve rich document content");
|
|
assert.ok(!(await request("sync.outbox.list", { limit: 200, includeConflicts: true })).items.some((item) => item.id === recoveryCandidate.id), "recovery should remove the failed update");
|
|
assert.ok((await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.kind === "memo.create" && item.entityId === recovered.memo.id), "recovery should queue the new local note for upload");
|
|
assert.ok((await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.kind === "memo.update" && item.entityId === recovered.memo.id && JSON.stringify(item.payload.contentJson) === JSON.stringify(recoveryCandidate.payload.contentJson)), "recovery should queue a successor update that preserves rich content after create acknowledgement");
|
|
|
|
const conflictMemo = await request("memo.create", { notebookId: inbox.id, title: "Conflict test", contentMarkdown: "local conflict", tags: [] });
|
|
const conflictCandidate = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.entityId === conflictMemo.memo.id);
|
|
assert.ok(conflictCandidate, "conflict test should create an outbox item");
|
|
await request("sync.outbox.fail", { id: conflictCandidate.id, error: "test conflict", conflict: true });
|
|
assert.ok(!(await request("sync.outbox.list", { limit: 200 })).items.some((item) => item.id === conflictCandidate.id), "conflicts must not be returned to the automatic retry loop");
|
|
assert.ok((await request("sync.outbox.list", { limit: 200, includeConflicts: true })).items.some((item) => item.id === conflictCandidate.id && item.status === "conflict"), "conflicts should remain available to explicit recovery actions");
|
|
await request("sync.outbox.discard", { id: conflictCandidate.id });
|
|
assert.equal((await request("sync.status")).conflict, 0);
|
|
|
|
child.stdin.end();
|
|
await new Promise((resolve) => child.once("close", resolve));
|
|
console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.list.tag", "memo.list.subtree", "memo.update", "memo.update.coalesce", "memo.revisions", "memo.restoreRevision", "memo.revision.cache", "tag.rename", "memo.moveBatch", "memo.pinBatch", "memo.deleteBatch", "memo.restore", "memo.emptyTrash", "memo.merge", "template.cache", "template.create.payload", "template.delete", "storage.backup", "storage.backups", "storage.restore", "sync.apply.merge-page-order", "sync.apply.deleted-notebook", "sync.outbox", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard"] }));
|