@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
753 lines (634 loc) • 29.1 kB
text/typescript
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from "vite-plus/test";
import { encodeAny } from "lib0/buffer";
import * as Y from "@y/y";
import {
CURRENT_VERSION_ID,
type VersionSnapshot,
} from "../../../extensions/Versioning/index.js";
import { createYHubVersioningEndpoints } from "../yhub.js";
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
import { createExtension } from "../../../editor/BlockNoteExtension.js";
// ---------------------------------------------------------------------------
// Fixture data — version entries now carry an `id` custom attribution (UUID).
// ---------------------------------------------------------------------------
const VERSION_ENTRY_1 = {
from: 1782218082853,
to: 1782218082853,
by: "user-1",
customAttributions: [
{ k: "type", v: "version" },
{ k: "id", v: "uuid-version-1" },
{ k: "name", v: "Test Version 1" },
],
};
const VERSION_ENTRY_2 = {
from: 1782218211312,
to: 1782218211312,
by: "user-2, user-3",
customAttributions: [
{ k: "type", v: "version" },
{ k: "id", v: "uuid-version-2" },
{ k: "name", v: "Test Version 2" },
],
};
// Snapshots as produced by `list()` (see `activityToSnapshot`): the activity
// entry's `to` timestamp becomes both `createdAt` and `updatedAt`. The
// changeset/rollback APIs are now driven by these timestamps directly, so the
// endpoints no longer make an activity lookup to resolve them. The entry's
// comma-separated `by` user-ids are split into the snapshot's raw `by` array.
const SNAPSHOT_1: VersionSnapshot = {
id: "uuid-version-1",
name: "Test Version 1",
createdAt: VERSION_ENTRY_1.to,
updatedAt: VERSION_ENTRY_1.to,
by: ["user-1"],
};
const SNAPSHOT_2: VersionSnapshot = {
id: "uuid-version-2",
name: "Test Version 2",
createdAt: VERSION_ENTRY_2.to,
updatedAt: VERSION_ENTRY_2.to,
by: ["user-2", "user-3"],
};
const PATCH_RESPONSE = { success: true, message: "Document updated" };
function makeChangeset(
opts: { nextDoc?: boolean; attributions?: boolean } = {},
) {
const doc = new Y.Doc();
const frag = doc.get("default", "XmlFragment");
frag.insert(0, ["hello"]);
return {
prevDoc: Y.encodeStateAsUpdate(new Y.Doc()),
...(opts.nextDoc !== false ? { nextDoc: Y.encodeStateAsUpdate(doc) } : {}),
...(opts.attributions ? { attributions: new Uint8Array([0]) } : {}),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const BASE_URL = "https://yhub.test";
const ORG = "test-org";
const DOC_ID = "test-doc";
// The factory returns a callback that receives the editor instance (used to
// stamp `create`d snapshots with the current cursor user's id). Author ids are
// passed through raw on `VersionSnapshot.by` — resolving them to user info is
// the view layer's job, not the endpoints'. These tests create a bare editor
// with no collaboration extensions, so `create` gets no author id.
function makeEndpoints() {
const editor = BlockNoteEditor.create();
return createYHubVersioningEndpoints({
baseUrl: BASE_URL,
org: ORG,
docId: DOC_ID,
activityLimit: 50,
})(editor);
}
// A lightweight stand-in for the real `ySync` extension. `getVersionNamesMap`
// in yhub.ts reads the live collaboration doc exclusively via
// `editor.getExtension("ySync")?.fragment.doc`, so a stub that just exposes the
// fragment is enough to exercise the mutable `__bn_version_names` name store
// without wiring up the full collaboration/prosemirror sync machinery.
const ySyncStub = (fragment: Y.Type) =>
createExtension({ key: "ySync", fragment } as any);
// Build endpoints against an editor that has a `ySync` extension whose fragment
// belongs to `doc`, so the mutable version-name store on `doc` is reachable.
function makeCollabEndpoints(doc: Y.Doc) {
const fragment = doc.get("default", "XmlFragment") as unknown as Y.Type;
(fragment as any).insert(0, ["hello"]);
const editor = BlockNoteEditor.create({
extensions: [ySyncStub(fragment)],
});
const endpoints = createYHubVersioningEndpoints({
baseUrl: BASE_URL,
org: ORG,
docId: DOC_ID,
})(editor);
return { endpoints, fragment };
}
function mockFetchResponse(body: unknown, status = 200) {
const encoded = encodeAny(body);
return new Response(encoded as Blob | BufferSource, {
status,
statusText: status === 200 ? "OK" : "Error",
});
}
function makeFragment(): Y.Type {
const doc = new Y.Doc();
const frag = doc.get("default", "XmlFragment");
frag.insert(0, ["test content"]);
return frag;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("createYHubVersioningEndpoints", () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, "fetch");
});
afterEach(() => {
vi.restoreAllMocks();
});
// -------------------------------------------------------------------------
// list
// -------------------------------------------------------------------------
describe("list", () => {
it("returns version-tagged entries using the id attribution as snapshot id", async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse([VERSION_ENTRY_2, VERSION_ENTRY_1]),
);
// Current-version probe: latest edit of any kind, then latest version
// marker. Both are VERSION_ENTRY_2, so latest edit == latest marker → no
// synthetic "current version" entry.
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2]));
const endpoints = makeEndpoints();
const snapshots = await endpoints.list();
expect(snapshots).toHaveLength(2);
expect(snapshots[0].id).toBe("uuid-version-2");
expect(snapshots[0].name).toBe("Test Version 2");
// The comma-separated `by` user-ids are split into a raw id array —
// never resolved to usernames here (that's the view layer's job).
expect(snapshots[0].by).toEqual(["user-2", "user-3"]);
expect(snapshots[0].secondaryLabel).toBeUndefined();
expect(snapshots[1].id).toBe("uuid-version-1");
expect(snapshots[1].name).toBe("Test Version 1");
expect(snapshots[1].by).toEqual(["user-1"]);
});
it("fetches the full activity timeline (no type:version filter) with grouping defaults", async () => {
// 1: full activity timeline. 2: latest edit of any kind. 3: latest
// version marker (the current-version probe makes both 2 & 3).
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
const endpoints = makeEndpoints();
await endpoints.list();
expect(fetchSpy).toHaveBeenCalledTimes(3);
const versionUrl = new URL(fetchSpy.mock.calls[0][0] as string);
expect(versionUrl.pathname).toBe(`/activity/${ORG}/${DOC_ID}`);
// The `type:version` overlay filter is dropped so history entries are
// returned too.
expect(versionUrl.searchParams.get("withCustomAttributions")).toBe(null);
expect(versionUrl.searchParams.get("customAttributions")).toBe("true");
// Grouping default is applied.
expect(versionUrl.searchParams.get("groupMaxGap")).toBe("10000");
// Probe A: the latest entry of *any* type (no marker filter).
const latestUrl = new URL(fetchSpy.mock.calls[1][0] as string);
expect(latestUrl.pathname).toBe(`/activity/${ORG}/${DOC_ID}`);
expect(latestUrl.searchParams.get("limit")).toBe("1");
expect(latestUrl.searchParams.has("withCustomAttributions")).toBe(false);
// Probe B: the latest *version marker*, server-filtered to `type:version`
// so grouping/mergeUsers can't conflate it with a later edit.
const markerUrl = new URL(fetchSpy.mock.calls[2][0] as string);
expect(markerUrl.pathname).toBe(`/activity/${ORG}/${DOC_ID}`);
expect(markerUrl.searchParams.get("limit")).toBe("1");
expect(markerUrl.searchParams.get("withCustomAttributions")).toBe(
"type:version",
);
});
it("forwards group + groupMaxDuration params when configured", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
const endpoints = createYHubVersioningEndpoints({
baseUrl: BASE_URL,
org: ORG,
docId: DOC_ID,
activityLimit: 50,
group: true,
groupMaxDuration: 5000,
})(BlockNoteEditor.create());
await endpoints.list();
const versionUrl = new URL(fetchSpy.mock.calls[0][0] as string);
expect(versionUrl.searchParams.get("group")).toBe("true");
expect(versionUrl.searchParams.get("groupMaxDuration")).toBe("5000");
});
it("omits group params by default while keeping the groupMaxGap default", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
// `makeEndpoints` builds the factory with no group/groupMaxDuration opts.
const endpoints = makeEndpoints();
await endpoints.list();
const versionUrl = new URL(fetchSpy.mock.calls[0][0] as string);
expect(versionUrl.searchParams.get("group")).toBe(null);
expect(versionUrl.searchParams.get("groupMaxDuration")).toBe(null);
expect(versionUrl.searchParams.get("groupMaxGap")).toBe("10000");
// mergeUsers is only forwarded when explicitly configured.
expect(versionUrl.searchParams.get("mergeUsers")).toBe(null);
});
it("maps both named version entries and plain history entries", async () => {
const namedEntry = {
from: 2000,
to: 2000,
by: "user-1",
customAttributions: [
{ k: "type", v: "version" },
{ k: "id", v: "v1" },
{ k: "name", v: "Named" },
],
};
const historyEntry = {
from: 1000,
to: 1000,
by: "user-2",
};
fetchSpy.mockResolvedValueOnce(
mockFetchResponse([namedEntry, historyEntry]),
);
// Current-version probe: latest edit == latest marker == `namedEntry`, so
// no synthetic current row.
fetchSpy.mockResolvedValueOnce(mockFetchResponse([namedEntry]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([namedEntry]));
const endpoints = makeEndpoints();
const snapshots = await endpoints.list();
const named = snapshots.find((s) => s.name === "Named");
expect(named).toBeDefined();
expect(named!.id).toBe("v1");
// `historyEntry` is at index 1 in the mocked entries array, so its
// history id embeds that index: `history-<to>-<index>`.
const history = snapshots.find((s) => s.id === "history-1000-1");
expect(history).toBeDefined();
expect(history!.name).toBeUndefined();
});
it("returns empty array when no versions exist", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
const endpoints = makeEndpoints();
const snapshots = await endpoints.list();
expect(snapshots).toEqual([]);
});
it("sorts snapshots newest-first", async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse([VERSION_ENTRY_1, VERSION_ENTRY_2]),
);
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2]));
const endpoints = makeEndpoints();
const snapshots = await endpoints.list();
expect(snapshots[0].createdAt).toBeGreaterThan(snapshots[1].createdAt);
});
it("silently skips entries without an id attribution", async () => {
const noIdEntry = {
from: 1782218082853,
to: 1782218082853,
by: "Bad Entry",
customAttributions: [{ k: "type", v: "version" }],
};
fetchSpy.mockResolvedValueOnce(
mockFetchResponse([VERSION_ENTRY_1, noIdEntry]),
);
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1]));
const endpoints = makeEndpoints();
const snapshots = await endpoints.list();
expect(snapshots).toHaveLength(1);
expect(snapshots[0].id).toBe("uuid-version-1");
});
it("prefers the mutable __bn_version_names name over the attribution name", async () => {
const doc = new Y.Doc();
// The `ySync` extension's fragment belongs to `doc`, so the mutable
// name store on `doc` is what `getVersionNamesMap` reads.
const { endpoints } = makeCollabEndpoints(doc);
// Rename version "v1" in the mutable store on the live doc.
doc.get("__bn_version_names").setAttr("v1", "Renamed");
const versionEntry = {
from: 1782218082853,
to: 1782218082853,
by: "user-1",
customAttributions: [
{ k: "type", v: "version" },
{ k: "id", v: "v1" },
{ k: "name", v: "Original" },
],
};
// 1: activity fetch. 2 & 3: current-version probe (latest edit, latest
// marker) — same entry both times, so no newer edit and no current row.
fetchSpy.mockResolvedValueOnce(mockFetchResponse([versionEntry]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([versionEntry]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([versionEntry]));
const snapshots = await endpoints.list();
expect(snapshots).toHaveLength(1);
expect(snapshots[0].id).toBe("v1");
expect(snapshots[0].name).toBe("Renamed");
});
it("falls back to the attribution name when the store has no entry", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1]));
const endpoints = makeEndpoints();
const snapshots = await endpoints.list();
expect(snapshots).toHaveLength(1);
expect(snapshots[0].name).toBe("Test Version 1");
});
it("prepends a 'current version' entry when there are edits beyond the latest version", async () => {
// A more recent edit than VERSION_ENTRY_2, by a different author.
const latestEdit = {
from: 1782218300000,
to: 1782218300000,
by: "user-4",
};
// 1: activity list. 2: latest edit of any kind (the newer edit). 3: latest
// version marker (VERSION_ENTRY_2). Since latestEdit.to > VERSION_ENTRY_2.to
// a synthetic current-version row is prepended.
fetchSpy.mockResolvedValueOnce(
mockFetchResponse([VERSION_ENTRY_2, VERSION_ENTRY_1]),
);
fetchSpy.mockResolvedValueOnce(mockFetchResponse([latestEdit]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2]));
const endpoints = makeEndpoints();
const snapshots = await endpoints.list();
expect(snapshots).toHaveLength(3);
expect(snapshots[0].id).toBe(CURRENT_VERSION_ID);
expect(snapshots[0].createdAt).toBe(latestEdit.to);
expect(snapshots[0].by).toEqual(["user-4"]);
expect(snapshots[0].secondaryLabel).toBeUndefined();
// The real version markers follow, newest-first.
expect(snapshots[1].id).toBe("uuid-version-2");
expect(snapshots[2].id).toBe("uuid-version-1");
});
it("prepends 'current version' even when the newest activity entry is a history edit (regression: grouping absorbing the marker)", async () => {
// This is the exact regression the self-contained current-version probe
// fixes: `list()` maps the whole timeline, so its newest snapshot is a
// plain edit — deriving the latest-version time from that list would make
// the guard compare a value against itself and never surface a current
// row. Because the marker is fetched independently (probe 3), the edit is
// correctly recognised as newer than the latest *version marker*.
const historyEdit = {
from: 1782218300000,
to: 1782218300000,
by: "user-9",
};
// 1: activity list — newest entry is the history edit, not a marker.
fetchSpy.mockResolvedValueOnce(
mockFetchResponse([historyEdit, VERSION_ENTRY_2, VERSION_ENTRY_1]),
);
// 2: latest edit of any kind → the history edit.
fetchSpy.mockResolvedValueOnce(mockFetchResponse([historyEdit]));
// 3: latest version marker → VERSION_ENTRY_2 (older than the edit).
fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2]));
const endpoints = makeEndpoints();
const snapshots = await endpoints.list();
expect(snapshots[0].id).toBe(CURRENT_VERSION_ID);
expect(snapshots[0].createdAt).toBe(historyEdit.to);
expect(snapshots[0].by).toEqual(["user-9"]);
});
it("forwards the mergeUsers param when configured", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
fetchSpy.mockResolvedValueOnce(mockFetchResponse([]));
const endpoints = createYHubVersioningEndpoints({
baseUrl: BASE_URL,
org: ORG,
docId: DOC_ID,
mergeUsers: true,
})(BlockNoteEditor.create());
await endpoints.list();
const versionUrl = new URL(fetchSpy.mock.calls[0][0] as string);
expect(versionUrl.searchParams.get("mergeUsers")).toBe("true");
});
});
// -------------------------------------------------------------------------
// create
// -------------------------------------------------------------------------
describe("create", () => {
it("PATCHes with type:version, id, and name attributions and returns optimistic snapshot", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
const endpoints = makeEndpoints();
const snapshot = await endpoints.create!(makeFragment(), {
name: "My Version",
});
// Only one fetch call (PATCH) — no activity fetch
expect(fetchSpy).toHaveBeenCalledOnce();
const [patchUrl, patchInit] = fetchSpy.mock.calls[0];
expect(patchUrl).toBe(`${BASE_URL}/ydoc/${ORG}/${DOC_ID}`);
expect(patchInit.method).toBe("PATCH");
expect(patchInit.body).toBeInstanceOf(Uint8Array);
// Optimistic snapshot has a UUID id and the provided name. With no
// yCursor extension there's no current user to attribute it to.
expect(snapshot.id).toMatch(/^[0-9a-f-]+$/);
expect(snapshot.name).toBe("My Version");
expect(snapshot.createdAt).toBeGreaterThan(0);
expect(snapshot.by).toBeUndefined();
});
it("stamps the optimistic snapshot with the cursor user's id", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
// Stub the editor so the yCursor extension reports a current user.
const editor = {
getExtension: (key: string) =>
key === "yCursor"
? {
getUser: () => ({ id: "user-1", name: "Alice", color: "#f00" }),
}
: undefined,
} as unknown as BlockNoteEditor<any, any, any>;
const endpoints = createYHubVersioningEndpoints({
baseUrl: BASE_URL,
org: ORG,
docId: DOC_ID,
})(editor);
const snapshot = await endpoints.create!(makeFragment(), {
name: "My Version",
});
expect(snapshot.by).toBe("user-1");
// The PATCH is attributed to the same user.
const patchUrl = new URL(fetchSpy.mock.calls[0][0] as string);
expect(patchUrl.searchParams.get("userid")).toBe("user-1");
});
it("creates a version without a name", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
const endpoints = makeEndpoints();
const snapshot = await endpoints.create!(makeFragment());
expect(snapshot.name).toBeUndefined();
expect(snapshot.id).toMatch(/^[0-9a-f-]+$/);
});
it("writes the version name into the __bn_version_names Y.Map on create", async () => {
const doc = new Y.Doc();
const { endpoints, fragment } = makeCollabEndpoints(doc);
fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
const snapshot = await endpoints.create!(fragment as any, {
name: "My Version",
});
const names = doc.get("__bn_version_names");
expect(names.getAttr(snapshot.id as string)).toBe("My Version");
});
it("throws when the fragment is not attached to a doc", async () => {
const endpoints = makeEndpoints();
const detached = { doc: null } as unknown as Y.Type;
await expect(
endpoints.create!(detached, { name: "fail" }),
).rejects.toThrow("not attached to a Y.Doc");
});
it("getContent works on the returned snapshot without an extra lookup", async () => {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(PATCH_RESPONSE));
const cs = makeChangeset();
fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
const endpoints = makeEndpoints();
const snapshot = await endpoints.create!(makeFragment(), {
name: "new",
});
const content = await endpoints.getContent(snapshot);
expect(content).toBeInstanceOf(Uint8Array);
// PATCH + changeset — the timestamp comes from the snapshot itself, so
// there's no activity lookup.
expect(fetchSpy).toHaveBeenCalledTimes(2);
const url = new URL(fetchSpy.mock.calls[1][0] as string);
expect(url.searchParams.get("to")).toBe(String(snapshot.createdAt));
});
});
// -------------------------------------------------------------------------
// getContent
// -------------------------------------------------------------------------
describe("getContent", () => {
it("fetches the changeset by to=<snapshot.createdAt> with no activity lookup", async () => {
// changeset fetch
const cs = makeChangeset();
fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
const endpoints = makeEndpoints();
const content = await endpoints.getContent(SNAPSHOT_1);
expect(content).toBeInstanceOf(Uint8Array);
expect(content.byteLength).toBeGreaterThan(0);
// The snapshot carries its own timestamp, so only the changeset is fetched.
expect(fetchSpy).toHaveBeenCalledOnce();
// changeset reconstructed by timestamp, NOT by custom attribution
const url = new URL(fetchSpy.mock.calls[0][0] as string);
expect(url.pathname).toBe(`/changeset/${ORG}/${DOC_ID}`);
expect(url.searchParams.get("ydoc")).toBe("true");
expect(url.searchParams.get("to")).toBe(String(SNAPSHOT_1.createdAt));
expect(url.searchParams.has("from")).toBe(false);
expect(url.searchParams.has("withCustomAttributions")).toBe(false);
});
it("throws when changeset has no nextDoc", async () => {
fetchSpy.mockResolvedValueOnce(
mockFetchResponse({ prevDoc: new Uint8Array() }),
);
const endpoints = makeEndpoints();
await expect(endpoints.getContent(SNAPSHOT_1)).rejects.toThrow(
"no document state",
);
});
});
// -------------------------------------------------------------------------
// getAttributions
// -------------------------------------------------------------------------
describe("getAttributions", () => {
it("fetches attributions between two versions", async () => {
const endpoints = makeEndpoints();
// changeset fetch (timestamps come straight from the snapshots)
const cs = makeChangeset({ attributions: true });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
try {
await endpoints.getAttributions!(SNAPSHOT_2, SNAPSHOT_1);
} catch {
// Expected — mock attributions aren't valid Y.ContentMap
}
// Only the changeset is fetched — no activity lookups.
expect(fetchSpy).toHaveBeenCalledOnce();
const url = new URL(fetchSpy.mock.calls[0][0] as string);
expect(url.searchParams.get("from")).toBe(String(SNAPSHOT_1.createdAt));
expect(url.searchParams.get("to")).toBe(String(SNAPSHOT_2.createdAt));
expect(url.searchParams.get("attributions")).toBe("true");
});
it("uses from=0 when compareTo is omitted", async () => {
const endpoints = makeEndpoints();
// changeset fetch
const cs = makeChangeset({ attributions: true });
fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
try {
await endpoints.getAttributions!(SNAPSHOT_1);
} catch {
// Expected
}
const url = new URL(fetchSpy.mock.calls[0][0] as string);
expect(url.searchParams.get("from")).toBe("0");
});
it("throws when changeset has no attributions", async () => {
const endpoints = makeEndpoints();
// changeset without attributions
fetchSpy.mockResolvedValueOnce(
mockFetchResponse({ nextDoc: new Uint8Array() }),
);
await expect(endpoints.getAttributions!(SNAPSHOT_1)).rejects.toThrow(
"no attributions",
);
});
});
// -------------------------------------------------------------------------
// restore
// -------------------------------------------------------------------------
describe("restore", () => {
it("fetches content and issues rollback (no backup)", async () => {
const endpoints = makeEndpoints();
const cs = makeChangeset();
// 1: GET /changeset (getContentAt via to=<snapshot.createdAt>)
fetchSpy.mockResolvedValueOnce(mockFetchResponse(cs));
// 2: POST /rollback
fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
const content = await endpoints.restore!(makeFragment(), SNAPSHOT_1);
// No backup PATCH and no activity lookup — just changeset + rollback.
expect(fetchSpy).toHaveBeenCalledTimes(2);
// 1st call: GET changeset by timestamp
const csUrl = new URL(fetchSpy.mock.calls[0][0] as string);
expect(csUrl.pathname).toBe(`/changeset/${ORG}/${DOC_ID}`);
expect(csUrl.searchParams.get("to")).toBe(String(SNAPSHOT_1.createdAt));
// 2nd call: POST rollback
const [rollbackUrl, rollbackInit] = fetchSpy.mock.calls[1];
expect(rollbackUrl).toContain(`/rollback/${ORG}/${DOC_ID}`);
expect(rollbackInit.method).toBe("POST");
expect(content).toBeInstanceOf(Uint8Array);
});
});
// -------------------------------------------------------------------------
// rename
// -------------------------------------------------------------------------
describe("rename", () => {
it("provides a rename endpoint", () => {
const endpoints = makeEndpoints();
expect(typeof endpoints.rename).toBe("function");
});
it("rename sets the name in the Y.Map", async () => {
const doc = new Y.Doc();
const { endpoints, fragment } = makeCollabEndpoints(doc);
fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
const snapshot = await endpoints.create!(fragment as any, {
name: "Old",
});
await endpoints.rename!(snapshot, "New");
const names = doc.get("__bn_version_names");
expect(names.getAttr(snapshot.id as string)).toBe("New");
});
it("rename with no name clears the entry", async () => {
const doc = new Y.Doc();
const { endpoints, fragment } = makeCollabEndpoints(doc);
fetchSpy.mockResolvedValueOnce(mockFetchResponse({ success: true }));
const snapshot = await endpoints.create!(fragment as any, {
name: "Old",
});
await endpoints.rename!(snapshot, undefined);
const names = doc.get("__bn_version_names");
expect(names.hasAttr(snapshot.id as string)).toBe(false);
});
});
// -------------------------------------------------------------------------
// error handling
// -------------------------------------------------------------------------
describe("error handling", () => {
it("throws on non-OK HTTP responses", async () => {
fetchSpy.mockResolvedValueOnce(
new Response("Not Found", { status: 404, statusText: "Not Found" }),
);
const endpoints = makeEndpoints();
await expect(endpoints.list()).rejects.toThrow(
"YHub request failed: 404",
);
});
});
});