Files
docmost-sync/test/client-rest.test.ts
vvzvlad 531b320776 feat(sync): add git vault layer (§5) and the Docmost->vault pull cycle (§6)
Turn the read-only mirror into a git-backed pull cycle. Read-only toward Docmost.

- git.ts (VaultGit): system-git wrapper, all ops cwd=vaultPath (vault is its own
  repo under data/vault, never the source repo); ensureRepo/branches main+docmost,
  commit with provenance (author/committer identity + Docmost-Sync-Source trailer,
  §7.3), merge with conflict surfacing (no auto-resolve, §9), isMergeInProgress;
  GIT_DIR/GIT_WORK_TREE stripped from env (§12 cwd isolation)
- stabilize.ts: normalize-on-write (one export->import->export fixpoint pass, §11)
- reconcile.ts: pure planReconciliation (add/update/move/delete by pageId) +
  decideAbsenceDeletions gate
- pull.ts: write/commit on docmost -> merge into main; listSpaceTree completeness
  signal suppresses absence-deletions on a partial fetch (§8); mass-delete guard;
  merge-in-progress guard makes re-runs converge (§12); move old-path removal only
  on successful write
- docmost-client: listSpaceTree({pages, complete}) without touching the 1:1-copied
  enumerateSpacePages
- tests: reconcile planner + decideAbsenceDeletions, VaultGit incl. real temp-repo
  merge conflict, listSpaceTree completeness (586 green)

Push to a git remote and the FS->Docmost direction are deferred to the next increment.
2026-06-16 23:57:50 +03:00

718 lines
27 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
});
});