Phase-4 REST-binding coverage for the god-object client (test-only; product
code untouched).
- deletePage/restorePage/listTrash: bodies use pageId (not id), no
permanentlyDelete, trash is per-space + paginates (SPEC §8 deletion mirroring)
- listRecentSince: /pages/recent body (limit:100, spaceId omitted when unset,
cursor threaded, envelope unwrap, cutoff)
- movePage (default position, parentPageId:null); getPage subpages degradation +
{{SUBPAGES}}; getPageJson default content
- validateDocUrls bare/edge node-shape tolerance (no XSS-reject duplication —
that stays in client-pure)
- seam: preauthed client + MockAdapter on the private axios instance, restored
in afterEach; no real network
1114 lines
43 KiB
TypeScript
1114 lines
43 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import axios from 'axios';
|
|
import MockAdapter from 'axios-mock-adapter';
|
|
// The DocmostClient source actually lives at packages/docmost-client/src/client.ts
|
|
// (NOT under .../lib/). The package barrel re-exports it, and vitest resolves the
|
|
// ".js" specifier to the TS source, so the real interceptors run.
|
|
import { DocmostClient } from '../packages/docmost-client/src/client.js';
|
|
|
|
/**
|
|
* Integration tests for DocmostClient's REST surface using axios-mock-adapter.
|
|
*
|
|
* Two distinct axios instances are in play and must each be mocked separately:
|
|
*
|
|
* 1. this.client (a dedicated axios.create() instance reachable via the
|
|
* PRIVATE field (client as any).client). Every JSON REST call AND the
|
|
* 401/403 response interceptor live here. Attaching MockAdapter to THIS
|
|
* instance is what makes the real interceptor run on mocked responses.
|
|
*
|
|
* 2. The global default `axios` import. performLogin() and getCollabToken()
|
|
* (in lib/auth-utils.ts) use BARE axios.post(...), so /auth/login and
|
|
* /auth/collab-token are NOT seen by this.client's interceptor and must be
|
|
* mocked on the shared default axios instance instead (same trick the
|
|
* existing auth-utils.test.ts relies on).
|
|
*
|
|
* Helper newClient() returns both the client and freshly-installed mocks; tests
|
|
* close over them. afterEach resets every mock and restores spies.
|
|
*/
|
|
|
|
const BASE_URL = 'https://docmost.example/api';
|
|
|
|
// Track every mock created so afterEach can reset/restore them all even if a
|
|
// test forgets — keeps handler registrations from leaking across tests.
|
|
let activeMocks: MockAdapter[] = [];
|
|
|
|
function instanceMock(client: DocmostClient): MockAdapter {
|
|
const m = new MockAdapter((client as any).client);
|
|
activeMocks.push(m);
|
|
return m;
|
|
}
|
|
|
|
function globalAxiosMock(): MockAdapter {
|
|
const m = new MockAdapter(axios);
|
|
activeMocks.push(m);
|
|
return m;
|
|
}
|
|
|
|
/**
|
|
* Register a /auth/login handler on the global-axios mock that always succeeds
|
|
* with a valid authToken set-cookie (this is how performLogin extracts a token).
|
|
* Returns a getter for how many times login was hit so re-login counts can be
|
|
* asserted.
|
|
*/
|
|
function stubLoginSuccess(gmock: MockAdapter): { get count(): number } {
|
|
let n = 0;
|
|
gmock.onPost(`${BASE_URL}/auth/login`).reply(() => {
|
|
n++;
|
|
return [200, {}, { 'set-cookie': ['authToken=token-' + n + '; Path=/; HttpOnly'] }];
|
|
});
|
|
return {
|
|
get count() {
|
|
return n;
|
|
},
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const m of activeMocks) m.restore();
|
|
activeMocks = [];
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// paginateAll
|
|
// ---------------------------------------------------------------------------
|
|
describe('paginateAll', () => {
|
|
it('stops after a SHORT page (fewer items than the requested limit)', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
const gmock = globalAxiosMock();
|
|
stubLoginSuccess(gmock);
|
|
const imock = instanceMock(client);
|
|
|
|
let calls = 0;
|
|
imock.onPost('/items').reply((config) => {
|
|
calls++;
|
|
// Page 1 returns 3 items for a limit of 5 -> short page -> stop.
|
|
const body = JSON.parse(config.data);
|
|
expect(body.page).toBe(1);
|
|
return [200, { data: { items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] } }];
|
|
});
|
|
|
|
const out = await (client as any).paginateAll('/items', {}, 5);
|
|
expect(out.map((i: any) => i.id)).toEqual(['a', 'b', 'c']);
|
|
expect(calls).toBe(1); // never asked for page 2
|
|
});
|
|
|
|
it('stops on an EMPTY page', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
let calls = 0;
|
|
imock.onPost('/items').reply(() => {
|
|
calls++;
|
|
return [200, { data: { items: [] } }];
|
|
});
|
|
|
|
const out = await (client as any).paginateAll('/items', {}, 5);
|
|
expect(out).toEqual([]);
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('follows meta.hasNextPage across multiple FULL pages', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
// Two full pages (limit 2) where the first declares hasNextPage:true and the
|
|
// second declares false; the second page is full but its flag stops the loop.
|
|
imock
|
|
.onPost('/items')
|
|
.replyOnce(200, {
|
|
data: { items: [{ id: '1' }, { id: '2' }], meta: { hasNextPage: true } },
|
|
})
|
|
.onPost('/items')
|
|
.replyOnce(200, {
|
|
data: { items: [{ id: '3' }, { id: '4' }], meta: { hasNextPage: false } },
|
|
});
|
|
|
|
const out = await (client as any).paginateAll('/items', {}, 2);
|
|
expect(out.map((i: any) => i.id)).toEqual(['1', '2', '3', '4']);
|
|
});
|
|
|
|
it('truncates at the MAX_PAGES cap and emits a console.warn', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
|
|
// A server that ALWAYS returns a full page (limit 1 -> 1 item) and always
|
|
// claims hasNextPage:true. The only stop condition is the 50-page ceiling.
|
|
let calls = 0;
|
|
imock.onPost('/items').reply(() => {
|
|
calls++;
|
|
return [200, { data: { items: [{ id: 'x' + calls }], meta: { hasNextPage: true } } }];
|
|
});
|
|
|
|
const out = await (client as any).paginateAll('/items', {}, 1);
|
|
// MAX_PAGES is 50: exactly 50 fetches, 50 accumulated items.
|
|
expect(calls).toBe(50);
|
|
expect(out).toHaveLength(50);
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(String(warn.mock.calls[0][0])).toContain('50-page cap');
|
|
});
|
|
|
|
it('clamps the limit into the 1..100 range before sending it', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
const seenLimits: number[] = [];
|
|
imock.onPost('/big').reply((config) => {
|
|
seenLimits.push(JSON.parse(config.data).limit);
|
|
return [200, { data: { items: [] } }];
|
|
});
|
|
imock.onPost('/small').reply((config) => {
|
|
seenLimits.push(JSON.parse(config.data).limit);
|
|
return [200, { data: { items: [] } }];
|
|
});
|
|
|
|
await (client as any).paginateAll('/big', {}, 9999); // clamps down to 100
|
|
await (client as any).paginateAll('/small', {}, 0); // clamps up to 1
|
|
expect(seenLimits).toEqual([100, 1]);
|
|
});
|
|
|
|
it('reads items from BOTH the data.data.items and the data.items envelope', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
// data.data.items envelope (wrapped).
|
|
imock.onPost('/wrapped').reply(200, { data: { items: [{ id: 'w' }] } });
|
|
// bare data.items envelope (unwrapped).
|
|
imock.onPost('/flat').reply(200, { items: [{ id: 'f' }] });
|
|
|
|
const wrapped = await (client as any).paginateAll('/wrapped', {}, 100);
|
|
const flat = await (client as any).paginateAll('/flat', {}, 100);
|
|
expect(wrapped.map((i: any) => i.id)).toEqual(['w']);
|
|
expect(flat.map((i: any) => i.id)).toEqual(['f']);
|
|
});
|
|
|
|
it('drives a real public method (getSpaces -> /spaces) through paginateAll + filterSpace', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
imock.onPost('/spaces').reply(200, {
|
|
data: {
|
|
items: [
|
|
{ id: 's1', name: 'Space One', slug: 'one', extraneous: 'dropped' },
|
|
],
|
|
},
|
|
});
|
|
|
|
const spaces = await client.getSpaces();
|
|
expect(spaces).toHaveLength(1);
|
|
expect(spaces[0]).toMatchObject({ id: 's1', name: 'Space One', slug: 'one' });
|
|
// filterSpace must drop fields it does not whitelist.
|
|
expect(spaces[0]).not.toHaveProperty('extraneous');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// search
|
|
// ---------------------------------------------------------------------------
|
|
describe('search', () => {
|
|
it('clamps a too-large limit down to 100', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
let sentLimit: number | undefined;
|
|
imock.onPost('/search').reply((config) => {
|
|
sentLimit = JSON.parse(config.data).limit;
|
|
return [200, { data: [], success: true }];
|
|
});
|
|
|
|
await client.search('q', undefined, 9999);
|
|
expect(sentLimit).toBe(100);
|
|
});
|
|
|
|
it('clamps a too-small limit up to 1', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
let sentLimit: number | undefined;
|
|
imock.onPost('/search').reply((config) => {
|
|
sentLimit = JSON.parse(config.data).limit;
|
|
return [200, { data: [], success: true }];
|
|
});
|
|
|
|
await client.search('q', undefined, 0);
|
|
expect(sentLimit).toBe(1);
|
|
});
|
|
|
|
it('omits the limit field entirely when no limit is supplied', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
let body: any;
|
|
imock.onPost('/search').reply((config) => {
|
|
body = JSON.parse(config.data);
|
|
return [200, { data: [], success: true }];
|
|
});
|
|
|
|
await client.search('hello', 'space-1');
|
|
expect(body).not.toHaveProperty('limit');
|
|
expect(body.query).toBe('hello');
|
|
expect(body.spaceId).toBe('space-1');
|
|
});
|
|
|
|
it('normalizes a BARE-ARRAY data envelope', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
imock.onPost('/search').reply(200, {
|
|
data: [{ id: 'p1', title: 'Result 1', extra: 'x' }],
|
|
success: true,
|
|
});
|
|
|
|
const res = await client.search('q');
|
|
expect(res.success).toBe(true);
|
|
expect(res.items).toHaveLength(1);
|
|
expect(res.items[0]).toMatchObject({ id: 'p1', title: 'Result 1' });
|
|
// filterSearchResult whitelists fields; "extra" must be gone.
|
|
expect(res.items[0]).not.toHaveProperty('extra');
|
|
});
|
|
|
|
it('normalizes a { items: [...] } data envelope', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
imock.onPost('/search').reply(200, {
|
|
data: { items: [{ id: 'p2', title: 'Result 2' }] },
|
|
success: true,
|
|
});
|
|
|
|
const res = await client.search('q');
|
|
expect(res.items).toHaveLength(1);
|
|
expect(res.items[0]).toMatchObject({ id: 'p2', title: 'Result 2' });
|
|
});
|
|
|
|
it('defaults success to false when the envelope omits it', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
imock.onPost('/search').reply(200, { data: { items: [] } });
|
|
|
|
const res = await client.search('q');
|
|
expect(res.success).toBe(false);
|
|
expect(res.items).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// listComments (cursor loop)
|
|
// ---------------------------------------------------------------------------
|
|
describe('listComments', () => {
|
|
it('loops on meta.nextCursor until it is null and concatenates every page', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
const seenCursors: (string | undefined)[] = [];
|
|
imock.onPost('/comments').reply((config) => {
|
|
const body = JSON.parse(config.data);
|
|
seenCursors.push(body.cursor);
|
|
if (!body.cursor) {
|
|
// First page: a nextCursor pushes the loop to a second request.
|
|
return [200, { data: { items: [{ id: 'c1' }], meta: { nextCursor: 'CUR2' } } }];
|
|
}
|
|
// Second page: nextCursor null -> loop terminates.
|
|
return [200, { data: { items: [{ id: 'c2' }], meta: { nextCursor: null } } }];
|
|
});
|
|
|
|
const comments = await client.listComments('page-1');
|
|
expect(comments.map((c: any) => c.id)).toEqual(['c1', 'c2']);
|
|
// First call carries no cursor; the second carries the server's nextCursor.
|
|
expect(seenCursors).toEqual([undefined, 'CUR2']);
|
|
});
|
|
|
|
it('parses stringified-JSON comment content into markdown per item', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
// createComment stores content as JSON.stringify(proseMirrorDoc); on read it
|
|
// comes back as a STRING that parseCommentContent must JSON.parse before the
|
|
// markdown converter can render it.
|
|
const stringifiedDoc = JSON.stringify({
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello comment' }] }],
|
|
});
|
|
|
|
imock.onPost('/comments').reply(200, {
|
|
data: {
|
|
items: [
|
|
{ id: 'c1', pageId: 'page-1', content: stringifiedDoc },
|
|
{ id: 'c2', pageId: 'page-1', content: '' }, // empty -> "" markdown
|
|
],
|
|
meta: { nextCursor: null },
|
|
},
|
|
});
|
|
|
|
const comments = await client.listComments('page-1');
|
|
expect(comments[0].content).toContain('Hello comment');
|
|
expect(comments[1].content).toBe('');
|
|
});
|
|
|
|
it('handles the bare (unwrapped) response envelope too', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
// response.data.data is absent -> the code falls back to response.data.
|
|
imock.onPost('/comments').reply(200, {
|
|
items: [{ id: 'only', pageId: 'page-1', content: '' }],
|
|
meta: { nextCursor: null },
|
|
});
|
|
|
|
const comments = await client.listComments('page-1');
|
|
expect(comments.map((c: any) => c.id)).toEqual(['only']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// checkNewComments
|
|
// ---------------------------------------------------------------------------
|
|
describe('checkNewComments', () => {
|
|
it('THROWS on an invalid "since" date rather than silently reporting nothing new', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
instanceMock(client);
|
|
|
|
await expect(client.checkNewComments('space-1', 'not-a-date')).rejects.toThrow(
|
|
/invalid "since" date/,
|
|
);
|
|
});
|
|
|
|
it('keeps only comments with createdAt STRICTLY greater than since (boundary excluded)', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
const since = '2026-06-16T10:00:00.000Z';
|
|
|
|
// One root page (no children) returned by sidebar-pages enumeration.
|
|
imock.onPost('/pages/sidebar-pages').reply(200, {
|
|
data: { items: [{ id: 'p1', title: 'Page 1', hasChildren: false }] },
|
|
});
|
|
|
|
// Comments on p1: one before, one exactly AT the boundary, one after.
|
|
imock.onPost('/comments').reply(200, {
|
|
data: {
|
|
items: [
|
|
{ id: 'before', pageId: 'p1', content: '', createdAt: '2026-06-16T09:59:59.000Z' },
|
|
{ id: 'equal', pageId: 'p1', content: '', createdAt: since }, // boundary -> excluded (> is strict)
|
|
{ id: 'after', pageId: 'p1', content: '', createdAt: '2026-06-16T10:00:01.000Z' },
|
|
],
|
|
meta: { nextCursor: null },
|
|
},
|
|
});
|
|
|
|
const res = await client.checkNewComments('space-1', since);
|
|
expect(res.totalNewComments).toBe(1);
|
|
expect(res.pagesWithNewComments).toBe(1);
|
|
expect(res.comments[0].comments.map((c: any) => c.id)).toEqual(['after']);
|
|
// The boundary-equal comment is NOT included.
|
|
expect(res.comments[0].comments.map((c: any) => c.id)).not.toContain('equal');
|
|
});
|
|
|
|
it('reports truncated=false for a small page set', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
imock.onPost('/pages/sidebar-pages').reply(200, {
|
|
data: { items: [{ id: 'p1', title: 'Page 1', hasChildren: false }] },
|
|
});
|
|
imock.onPost('/comments').reply(200, {
|
|
data: { items: [], meta: { nextCursor: null } },
|
|
});
|
|
|
|
const res = await client.checkNewComments('space-1', '2026-06-16T10:00:00.000Z');
|
|
expect(res.truncated).toBe(false);
|
|
expect(res.checkedPages).toBe(1);
|
|
expect(res.totalNewComments).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// listSpaceTree — completeness signal (SPEC §8)
|
|
// ---------------------------------------------------------------------------
|
|
describe('listSpaceTree (completeness)', () => {
|
|
// The walk seeds from /pages/sidebar-pages with only { spaceId } (roots), then
|
|
// fetches each hasChildren node's children with { spaceId, pageId }. We route
|
|
// by the presence of `pageId` in the request body.
|
|
it('returns complete:true and every node for a fully-fetched tree', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
imock.onPost('/pages/sidebar-pages').reply((config) => {
|
|
const body = JSON.parse(config.data);
|
|
if (!body.pageId) {
|
|
// Root level: one parent with children + one leaf.
|
|
return [
|
|
200,
|
|
{
|
|
data: {
|
|
items: [
|
|
{ id: 'root', title: 'Root', hasChildren: true },
|
|
{ id: 'leaf', title: 'Leaf', hasChildren: false },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
}
|
|
if (body.pageId === 'root') {
|
|
return [
|
|
200,
|
|
{ data: { items: [{ id: 'child', title: 'Child', hasChildren: false }] } },
|
|
];
|
|
}
|
|
return [200, { data: { items: [] } }];
|
|
});
|
|
|
|
const { pages, complete } = await client.listSpaceTree('space-1');
|
|
expect(complete).toBe(true);
|
|
expect(new Set(pages.map((p: any) => p.id))).toEqual(
|
|
new Set(['root', 'leaf', 'child']),
|
|
);
|
|
});
|
|
|
|
it('returns complete:false but still the other nodes when a branch fetch THROWS', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
imock.onPost('/pages/sidebar-pages').reply((config) => {
|
|
const body = JSON.parse(config.data);
|
|
if (!body.pageId) {
|
|
// Two parents, both claim children; one of them will fail to expand.
|
|
return [
|
|
200,
|
|
{
|
|
data: {
|
|
items: [
|
|
{ id: 'ok', title: 'Ok', hasChildren: true },
|
|
{ id: 'boom', title: 'Boom', hasChildren: true },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
}
|
|
if (body.pageId === 'ok') {
|
|
return [
|
|
200,
|
|
{ data: { items: [{ id: 'okchild', title: 'OkChild', hasChildren: false }] } },
|
|
];
|
|
}
|
|
// The 'boom' branch fails -> walk must continue, completeness must drop.
|
|
return [500, {}];
|
|
});
|
|
|
|
const { pages, complete } = await client.listSpaceTree('space-1');
|
|
// The failed branch flips completeness to false...
|
|
expect(complete).toBe(false);
|
|
// ...but the rest of the tree is still collected (no abort, no wipe signal).
|
|
expect(new Set(pages.map((p: any) => p.id))).toEqual(
|
|
new Set(['ok', 'boom', 'okchild']),
|
|
);
|
|
});
|
|
|
|
it('returns complete:false and no nodes when the seed (root) fetch fails', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
// Every sidebar-pages call fails -> listSidebarPages itself throws on the
|
|
// seed, so the walk returns empty + incomplete (never "0 pages, complete").
|
|
imock.onPost('/pages/sidebar-pages').reply(500, {});
|
|
|
|
const { pages, complete } = await client.listSpaceTree('space-1');
|
|
expect(complete).toBe(false);
|
|
expect(pages).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AUTH: 401 interceptor + re-login dedup + getCollabTokenWithReauth
|
|
// ---------------------------------------------------------------------------
|
|
describe('auth: 401 interceptor and re-login', () => {
|
|
it('re-authenticates ONCE and replays the request ONCE on a 401-then-200 sequence', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
// Silence performLogin's own console.error noise (not under test here).
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const gmock = globalAxiosMock();
|
|
const login = stubLoginSuccess(gmock);
|
|
const imock = instanceMock(client);
|
|
|
|
// Pre-authenticate so the very first ensureAuthenticated() login is already
|
|
// done; this isolates the count of EXTRA re-logins caused by the 401.
|
|
await client.login();
|
|
expect(login.count).toBe(1);
|
|
|
|
let getCalls = 0;
|
|
imock.onPost('/workspace/info').reply(() => {
|
|
getCalls++;
|
|
if (getCalls === 1) return [401, {}]; // expired token
|
|
return [200, { data: { id: 'ws-9', name: 'WS' }, success: true }];
|
|
});
|
|
|
|
const res = await client.getWorkspace();
|
|
expect(res.data.id).toBe('ws-9');
|
|
expect(getCalls).toBe(2); // original + exactly one replay
|
|
expect(login.count).toBe(2); // pre-auth + exactly one re-login
|
|
});
|
|
|
|
it('does NOT loop forever: a 401 on BOTH original and replay surfaces the error (config._retry guard)', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const login = stubLoginSuccess(globalAxiosMock());
|
|
const imock = instanceMock(client);
|
|
|
|
await client.login();
|
|
|
|
let getCalls = 0;
|
|
imock.onPost('/workspace/info').reply(() => {
|
|
getCalls++;
|
|
return [401, {}]; // always 401, even after re-login
|
|
});
|
|
|
|
await expect(client.getWorkspace()).rejects.toBeDefined();
|
|
// The replay sets config._retry, so the SECOND 401 is not retried again.
|
|
expect(getCalls).toBe(2);
|
|
// Only one re-login happened (the first 401); the replay's 401 did not
|
|
// trigger another because _retry was already set.
|
|
expect(login.count).toBe(2);
|
|
});
|
|
|
|
it('never retries the /auth/login request itself through the interceptor', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
const imock = instanceMock(client);
|
|
|
|
// Route a /auth/login call THROUGH this.client (so the interceptor sees it)
|
|
// and have it 401: the isLoginRequest guard must prevent any replay loop.
|
|
let hits = 0;
|
|
imock.onPost('/auth/login').reply(() => {
|
|
hits++;
|
|
return [401, {}];
|
|
});
|
|
|
|
await expect((client as any).client.post('/auth/login', {})).rejects.toBeDefined();
|
|
expect(hits).toBe(1); // exactly one hit, no interceptor-driven retry
|
|
});
|
|
|
|
it('surfaces the ORIGINAL error when the re-login attempt itself fails', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const gmock = globalAxiosMock();
|
|
// First login (pre-auth) succeeds; afterwards we flip login to fail.
|
|
let loginCalls = 0;
|
|
gmock.onPost(`${BASE_URL}/auth/login`).reply(() => {
|
|
loginCalls++;
|
|
if (loginCalls === 1) {
|
|
return [200, {}, { 'set-cookie': ['authToken=tok; Path=/'] }];
|
|
}
|
|
return [500, { msg: 'login down' }]; // re-login fails
|
|
});
|
|
const imock = instanceMock(client);
|
|
|
|
await client.login(); // pre-auth ok
|
|
|
|
imock.onPost('/workspace/info').reply(403, { msg: 'original-forbidden' });
|
|
|
|
let captured: any;
|
|
try {
|
|
await client.getWorkspace();
|
|
} catch (e) {
|
|
captured = e;
|
|
}
|
|
// The interceptor's catch returns Promise.reject(error) — the ORIGINAL 403,
|
|
// not the re-login's 500.
|
|
expect(captured?.response?.status).toBe(403);
|
|
expect(captured?.response?.data).toMatchObject({ msg: 'original-forbidden' });
|
|
});
|
|
|
|
it('dedups concurrent login() callers into ONE in-flight /auth/login request', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
const login = stubLoginSuccess(globalAxiosMock());
|
|
|
|
// Three simultaneous login() calls must collapse into a single request.
|
|
await Promise.all([client.login(), client.login(), client.login()]);
|
|
expect(login.count).toBe(1);
|
|
// loginPromise resets to null after the in-flight login settles.
|
|
expect((client as any).loginPromise).toBeNull();
|
|
});
|
|
|
|
it('resets loginPromise after a FAILED login so a later login can retry', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const gmock = globalAxiosMock();
|
|
|
|
let loginCalls = 0;
|
|
gmock.onPost(`${BASE_URL}/auth/login`).reply(() => {
|
|
loginCalls++;
|
|
if (loginCalls === 1) return [500, {}]; // first attempt fails
|
|
return [200, {}, { 'set-cookie': ['authToken=ok; Path=/'] }]; // second succeeds
|
|
});
|
|
|
|
await expect(client.login()).rejects.toBeDefined();
|
|
// .finally() cleared the memoized promise even on failure.
|
|
expect((client as any).loginPromise).toBeNull();
|
|
|
|
// A fresh login() therefore issues a brand-new request instead of returning
|
|
// the previously-rejected promise.
|
|
await client.login();
|
|
expect(loginCalls).toBe(2);
|
|
expect((client as any).loginPromise).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('auth: getCollabTokenWithReauth', () => {
|
|
it('re-authenticates once on a 401 from collab-token, then retries and returns the token', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const gmock = globalAxiosMock();
|
|
const login = stubLoginSuccess(gmock);
|
|
instanceMock(client);
|
|
|
|
let collabCalls = 0;
|
|
gmock.onPost(`${BASE_URL}/auth/collab-token`).reply(() => {
|
|
collabCalls++;
|
|
if (collabCalls === 1) return [401, {}]; // expired token
|
|
return [200, { data: { token: 'collab-good' } }];
|
|
});
|
|
|
|
const token = await (client as any).getCollabTokenWithReauth();
|
|
expect(token).toBe('collab-good');
|
|
expect(collabCalls).toBe(2); // original + retry
|
|
// ensureAuthenticated login (1) + re-login after the 401 (2).
|
|
expect(login.count).toBe(2);
|
|
});
|
|
|
|
it('rethrows a NON-auth collab-token error without retrying', async () => {
|
|
const client = new DocmostClient(BASE_URL, 'a@b.c', 'pw');
|
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const gmock = globalAxiosMock();
|
|
const login = stubLoginSuccess(gmock);
|
|
instanceMock(client);
|
|
|
|
let collabCalls = 0;
|
|
gmock.onPost(`${BASE_URL}/auth/collab-token`).reply(() => {
|
|
collabCalls++;
|
|
return [500, { msg: 'server down' }]; // non-auth error
|
|
});
|
|
|
|
await expect((client as any).getCollabTokenWithReauth()).rejects.toBeDefined();
|
|
expect(collabCalls).toBe(1); // no retry on a non-auth error
|
|
expect(login.count).toBe(1); // only the initial ensureAuthenticated login
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// REST-binding tests (test-strategy report §2 client-core "Integration add").
|
|
//
|
|
// These exercise the request body / endpoint shape the client SENDS for the
|
|
// methods the report flags individually (listRecentSince, movePage, the SPEC §8
|
|
// deletion-mirroring trio deletePage/restorePage/listTrash, getPage subpages,
|
|
// getPageJson defaults) plus the validateDocUrls unit cases. They deliberately
|
|
// do NOT re-cover the skip-list: the envelope (data?.data ?? data) and
|
|
// pagination are asserted ONCE above (paginateAll/search/listComments), the
|
|
// 401-reauth surface is above, and the security validators isSafeUrl /
|
|
// validateDocStructure / transformPage are covered in client-pure.test.ts.
|
|
//
|
|
// Seam: rather than mocking login, pre-authenticate by setting the private
|
|
// token + default Authorization header so ensureAuthenticated() is a no-op, and
|
|
// attach MockAdapter to the client's PRIVATE axios instance. No real network.
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* Build a client that is already authenticated (token + default header set) so
|
|
* ensureAuthenticated() short-circuits without ever hitting /auth/login. Mocks
|
|
* the client's own private axios instance and tracks it for afterEach cleanup.
|
|
*/
|
|
function preauthedClient(): { client: DocmostClient; mock: MockAdapter } {
|
|
const client = new DocmostClient(BASE_URL, 'e', 'p');
|
|
(client as any).token = 'test-token';
|
|
(client as any).client.defaults.headers.common['Authorization'] =
|
|
'Bearer test-token';
|
|
const mock = instanceMock(client);
|
|
return { client, mock };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// listRecentSince — REST binding to /pages/recent (report §2 client-core).
|
|
// The cursor walk itself lives in the pure collectRecentSince (covered in
|
|
// recent-since*.test.ts); here we assert ONLY the HTTP body the fetchPage
|
|
// closure sends and that the envelope is unwrapped correctly.
|
|
// ---------------------------------------------------------------------------
|
|
describe('listRecentSince (REST binding)', () => {
|
|
it('posts to /pages/recent with limit:100 and OMITS spaceId when undefined', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
let body: any;
|
|
mock.onPost('/pages/recent').reply((config) => {
|
|
body = JSON.parse(config.data);
|
|
// Single short page, all newer than the cutoff -> no second fetch.
|
|
return [
|
|
200,
|
|
{ data: { items: [{ id: 'r1', updatedAt: '2026-06-16T12:00:00.000Z' }], meta: { nextCursor: null } } },
|
|
];
|
|
});
|
|
|
|
const out = await client.listRecentSince(undefined, '2026-06-16T00:00:00.000Z');
|
|
expect(out.map((i: any) => i.id)).toEqual(['r1']);
|
|
expect(body.limit).toBe(100);
|
|
expect(body).not.toHaveProperty('spaceId'); // omitted when undefined
|
|
expect(body).not.toHaveProperty('cursor'); // omitted on the first page
|
|
});
|
|
|
|
it('INCLUDES spaceId in the body when one is given', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
let body: any;
|
|
mock.onPost('/pages/recent').reply((config) => {
|
|
body = JSON.parse(config.data);
|
|
return [200, { data: { items: [], meta: { nextCursor: null } } }];
|
|
});
|
|
|
|
await client.listRecentSince('space-7', '2026-06-16T00:00:00.000Z');
|
|
expect(body.spaceId).toBe('space-7');
|
|
expect(body.limit).toBe(100);
|
|
});
|
|
|
|
it('threads cursor across pages and unwraps both the data.data and meta.nextCursor envelope', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
const seenCursors: (string | undefined)[] = [];
|
|
mock.onPost('/pages/recent').reply((config) => {
|
|
const body = JSON.parse(config.data);
|
|
seenCursors.push(body.cursor);
|
|
if (!body.cursor) {
|
|
// Page 1: a full page of newer-than-cutoff items + a nextCursor.
|
|
return [
|
|
200,
|
|
{
|
|
data: {
|
|
items: [
|
|
{ id: 'a', updatedAt: '2026-06-16T12:00:00.000Z' },
|
|
{ id: 'b', updatedAt: '2026-06-16T11:00:00.000Z' },
|
|
],
|
|
meta: { nextCursor: 'CUR2' },
|
|
},
|
|
},
|
|
];
|
|
}
|
|
// Page 2 (cursor threaded): one more newer item, no further cursor.
|
|
return [
|
|
200,
|
|
{ data: { items: [{ id: 'c', updatedAt: '2026-06-16T10:00:00.000Z' }], meta: { nextCursor: null } } },
|
|
];
|
|
});
|
|
|
|
const out = await client.listRecentSince('space-1', '2026-06-16T00:00:00.000Z');
|
|
expect(out.map((i: any) => i.id)).toEqual(['a', 'b', 'c']);
|
|
// First page sends no cursor; the second carries the server's nextCursor.
|
|
expect(seenCursors).toEqual([undefined, 'CUR2']);
|
|
});
|
|
|
|
it('STOPS at the first item at/below the cutoff (descending scan) and skips no-id pagination', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
const since = '2026-06-16T10:00:00.000Z';
|
|
|
|
let calls = 0;
|
|
mock.onPost('/pages/recent').reply(() => {
|
|
calls++;
|
|
return [
|
|
200,
|
|
{
|
|
data: {
|
|
items: [
|
|
{ id: 'newer', updatedAt: '2026-06-16T11:00:00.000Z' }, // > cutoff -> kept
|
|
{ id: 'equal', updatedAt: since }, // <= cutoff -> stop here (boundary excluded)
|
|
{ id: 'older', updatedAt: '2026-06-16T09:00:00.000Z' }, // never reached
|
|
],
|
|
meta: { nextCursor: 'NEVER-USED' },
|
|
},
|
|
},
|
|
];
|
|
});
|
|
|
|
const out = await client.listRecentSince('space-1', since);
|
|
expect(out.map((i: any) => i.id)).toEqual(['newer']);
|
|
// Hitting the cutoff terminates the walk even though a nextCursor exists.
|
|
expect(calls).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// movePage — REST binding to /pages/move (report §2 client-core).
|
|
// ---------------------------------------------------------------------------
|
|
describe('movePage (REST binding)', () => {
|
|
it('uses the default position "a00000" when none is supplied', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
let body: any;
|
|
let url: string | undefined;
|
|
mock.onPost('/pages/move').reply((config) => {
|
|
body = JSON.parse(config.data);
|
|
url = config.url;
|
|
return [200, { success: true }];
|
|
});
|
|
|
|
await client.movePage('page-1', 'parent-9');
|
|
expect(url).toBe('/pages/move');
|
|
expect(body).toEqual({ pageId: 'page-1', parentPageId: 'parent-9', position: 'a00000' });
|
|
});
|
|
|
|
it('passes an explicit position through and accepts parentPageId:null (move to root)', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
let body: any;
|
|
mock.onPost('/pages/move').reply((config) => {
|
|
body = JSON.parse(config.data);
|
|
return [200, { success: true }];
|
|
});
|
|
|
|
await client.movePage('page-1', null, 'b50000');
|
|
expect(body.position).toBe('b50000');
|
|
expect(body.parentPageId).toBeNull(); // null is preserved, not coerced/dropped
|
|
expect(body.pageId).toBe('page-1');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ⭐ deletePage / restorePage / listTrash — the SPEC §8 / §16 deletion-mirroring
|
|
// path. The body key is `pageId` (NOT `id`); trash is PER-SPACE (sends spaceId)
|
|
// and paginates. Assert the request bodies precisely.
|
|
// ---------------------------------------------------------------------------
|
|
describe('deletePage / restorePage / listTrash (SPEC §8 deletion mirroring)', () => {
|
|
it('deletePage posts to /pages/delete with body { pageId } (NOT id, no permanentlyDelete)', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
let body: any;
|
|
let url: string | undefined;
|
|
mock.onPost('/pages/delete').reply((config) => {
|
|
url = config.url;
|
|
body = JSON.parse(config.data);
|
|
return [200, { success: true }];
|
|
});
|
|
|
|
await client.deletePage('page-42');
|
|
expect(url).toBe('/pages/delete');
|
|
expect(body).toEqual({ pageId: 'page-42' }); // exact body: pageId only
|
|
expect(body).not.toHaveProperty('id');
|
|
// Soft-delete by default: the destructive permanentlyDelete flag is never sent.
|
|
expect(body).not.toHaveProperty('permanentlyDelete');
|
|
});
|
|
|
|
it('restorePage posts to /pages/restore with body { pageId } (NOT id)', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
let body: any;
|
|
let url: string | undefined;
|
|
mock.onPost('/pages/restore').reply((config) => {
|
|
url = config.url;
|
|
body = JSON.parse(config.data);
|
|
return [200, { success: true }];
|
|
});
|
|
|
|
await client.restorePage('page-7');
|
|
expect(url).toBe('/pages/restore');
|
|
expect(body).toEqual({ pageId: 'page-7' });
|
|
expect(body).not.toHaveProperty('id');
|
|
});
|
|
|
|
it('listTrash posts to /pages/trash PER-SPACE (sends spaceId) and paginates', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
const bodies: any[] = [];
|
|
mock.onPost('/pages/trash').reply((config) => {
|
|
const body = JSON.parse(config.data);
|
|
bodies.push(body);
|
|
if (body.page === 1) {
|
|
// A full page (limit 100) with hasNextPage:true forces a second fetch.
|
|
const items = Array.from({ length: 100 }, (_, i) => ({ id: 't' + i, deletedAt: 'd', spaceId: 'space-3' }));
|
|
return [200, { data: { items, meta: { hasNextPage: true } } }];
|
|
}
|
|
return [200, { data: { items: [{ id: 'last', deletedAt: 'd', spaceId: 'space-3' }], meta: { hasNextPage: false } } }];
|
|
});
|
|
|
|
const trash = await client.listTrash('space-3');
|
|
expect(trash).toHaveLength(101);
|
|
// Every request carried the per-space scope; no workspace-wide variant.
|
|
expect(bodies.every((b) => b.spaceId === 'space-3')).toBe(true);
|
|
// Paginated: page 1 then page 2.
|
|
expect(bodies.map((b) => b.page)).toEqual([1, 2]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getPage — subpages fetch degrades gracefully; {{SUBPAGES}} placeholder
|
|
// resolution (report §2 client-core).
|
|
// ---------------------------------------------------------------------------
|
|
describe('getPage (subpages binding)', () => {
|
|
it('degrades gracefully when the subpages fetch fails (warn -> [] subpages, page still returned)', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
|
|
// /pages/info returns a page whose body has NO {{SUBPAGES}} placeholder.
|
|
mock.onPost('/pages/info').reply(200, {
|
|
data: {
|
|
id: 'p1',
|
|
slugId: 'slug-1',
|
|
title: 'Page 1',
|
|
spaceId: 'space-1',
|
|
content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body text only' }] }] },
|
|
},
|
|
});
|
|
// The subpages enumeration (/pages/sidebar-pages) blows up.
|
|
mock.onPost('/pages/sidebar-pages').reply(500, {});
|
|
|
|
const res = await client.getPage('p1');
|
|
expect(res.success).toBe(true);
|
|
expect(res.data.id).toBe('p1');
|
|
expect(res.data.content).toBe('Body text only'); // body survives the subpages failure
|
|
// No subpages present -> filterPage omits the field entirely.
|
|
expect(res.data).not.toHaveProperty('subpages');
|
|
expect(warn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves the {{SUBPAGES}} placeholder into a list when subpages exist', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
mock.onPost('/pages/info').reply(200, {
|
|
data: {
|
|
id: 'parent',
|
|
slugId: 'slug-parent',
|
|
title: 'Parent',
|
|
spaceId: 'space-1',
|
|
content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '{{SUBPAGES}}' }] }] },
|
|
},
|
|
});
|
|
// Two children for the placeholder to expand into.
|
|
mock.onPost('/pages/sidebar-pages').reply(200, {
|
|
data: {
|
|
items: [
|
|
{ id: 'kid-1', title: 'Kid One', hasChildren: false },
|
|
{ id: 'kid-2', title: 'Kid Two', hasChildren: false },
|
|
],
|
|
},
|
|
});
|
|
|
|
const res = await client.getPage('parent');
|
|
// {{SUBPAGES}} expanded into a "### Subpages" markdown list.
|
|
expect(res.data.content).toContain('### Subpages');
|
|
expect(res.data.content).toContain('- [Kid One](page:kid-1)');
|
|
expect(res.data.content).toContain('- [Kid Two](page:kid-2)');
|
|
expect(res.data.content).not.toContain('{{SUBPAGES}}');
|
|
// filterPage surfaces the subpages as {id,title} pairs.
|
|
expect(res.data.subpages).toEqual([
|
|
{ id: 'kid-1', title: 'Kid One' },
|
|
{ id: 'kid-2', title: 'Kid Two' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getPageJson — default content when the response omits it (report §2).
|
|
// ---------------------------------------------------------------------------
|
|
describe('getPageJson (default content)', () => {
|
|
it('defaults content to an empty doc when /pages/info returns none', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
mock.onPost('/pages/info').reply(200, {
|
|
data: {
|
|
id: 'p1',
|
|
slugId: 'slug-1',
|
|
title: 'No Content Page',
|
|
parentPageId: null,
|
|
spaceId: 'space-1',
|
|
updatedAt: '2026-06-16T00:00:00.000Z',
|
|
// no `content` field
|
|
},
|
|
});
|
|
|
|
const json = await client.getPageJson('p1');
|
|
expect(json.content).toEqual({ type: 'doc', content: [] });
|
|
expect(json.id).toBe('p1');
|
|
expect(json.title).toBe('No Content Page');
|
|
});
|
|
|
|
it('passes an existing content doc through untouched', async () => {
|
|
const { client, mock } = preauthedClient();
|
|
|
|
const doc = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }] };
|
|
mock.onPost('/pages/info').reply(200, {
|
|
data: { id: 'p2', slugId: 's2', title: 'T', parentPageId: 'par', spaceId: 'sp', updatedAt: 'u', content: doc },
|
|
});
|
|
|
|
const json = await client.getPageJson('p2');
|
|
expect(json.content).toEqual(doc);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateDocUrls — bare/edge node-shape tolerance (report §2 client-core
|
|
// "Unit add"). Reached directly via the private validator (a pure, synchronous,
|
|
// network-free guard); no mock needed. The XSS allowlist of isSafeUrl AND the
|
|
// unsafe-href/src rejection are already covered in client-pure.test.ts and are
|
|
// NOT re-tested here — these cases target validateDocUrls' node-shape
|
|
// robustness (missing attrs / empty href / null mark) specifically.
|
|
// ---------------------------------------------------------------------------
|
|
describe('validateDocUrls (bare/edge nodes)', () => {
|
|
// A plain instance is enough; validateDocUrls never touches the network.
|
|
function validator() {
|
|
const client = new DocmostClient(BASE_URL, 'e', 'p');
|
|
return (doc: any) => (client as any).validateDocUrls(doc);
|
|
}
|
|
|
|
it('does NOT throw (no NPE) when a link mark has no attrs object at all', () => {
|
|
// The `mark.attrs` guard short-circuits before isSafeUrl, so a link mark
|
|
// without an attrs object is a no-op rather than a null-deref crash.
|
|
const check = validator();
|
|
const doc = {
|
|
type: 'doc',
|
|
content: [{ type: 'text', text: 'x', marks: [{ type: 'link' }] }], // no attrs key
|
|
};
|
|
expect(() => check(doc)).not.toThrow();
|
|
});
|
|
|
|
it('does NOT throw when a link mark href is an empty string (safe-but-bare value)', () => {
|
|
// isSafeUrl treats a trimmed-empty href as harmless, so a bare empty href
|
|
// passes without being falsely rejected.
|
|
const check = validator();
|
|
const doc = {
|
|
type: 'doc',
|
|
content: [{ type: 'text', text: 'x', marks: [{ type: 'link', attrs: { href: '' } }] }],
|
|
};
|
|
expect(() => check(doc)).not.toThrow();
|
|
});
|
|
|
|
it('does NOT throw on a media node with no attrs at all', () => {
|
|
const check = validator();
|
|
const doc = { type: 'doc', content: [{ type: 'image' }] }; // no attrs object
|
|
expect(() => check(doc)).not.toThrow();
|
|
});
|
|
|
|
it('tolerates a null entry inside the marks array', () => {
|
|
const check = validator();
|
|
const doc = {
|
|
type: 'doc',
|
|
content: [{ type: 'text', text: 'x', marks: [null, { type: 'bold' }] }],
|
|
};
|
|
expect(() => check(doc)).not.toThrow();
|
|
});
|
|
});
|