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); }); }); // --------------------------------------------------------------------------- // 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 }); });