gogcli-mcp 2.23.1 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
3
+ import * as runner from '../src/runner.js';
4
+ import { finalizeGmailSearch, fetchGmailPages } from '../src/gmail-results.js';
5
+
6
+ vi.mock('../src/runner.js');
7
+
8
+ beforeEach(() => vi.clearAllMocks());
9
+
10
+ const THREADS = (nextPageToken: string) => JSON.stringify({
11
+ threads: [
12
+ { id: 'a', internalDateIso: '2026-08-01T09:00:00-04:00' },
13
+ { id: 'b', internalDateIso: '2026-08-12T12:36:00-04:00' },
14
+ { id: 'c', internalDateIso: '2026-08-05T09:00:00-04:00' },
15
+ ],
16
+ nextPageToken,
17
+ });
18
+
19
+ const THREAD_OPTS = { itemsKey: 'threads', method: 'users.threads.list', query: 'x' } as const;
20
+
21
+ const parse = (r: { content: { type: string; text?: string }[] }) =>
22
+ JSON.parse(r.content[0].text as string);
23
+
24
+ describe('finalizeGmailSearch — ordering', () => {
25
+ it('sorts newest-first by internalDateIso', async () => {
26
+ const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('')), THREAD_OPTS));
27
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['b', 'c', 'a']);
28
+ });
29
+
30
+ it('falls back to date when internalDateIso is absent', async () => {
31
+ const raw = JSON.stringify({
32
+ threads: [
33
+ { id: 'a', date: '2026-08-01T09:00:00-04:00' },
34
+ { id: 'b', date: '2026-08-09T09:00:00-04:00' },
35
+ ],
36
+ nextPageToken: '',
37
+ });
38
+ const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
39
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['b', 'a']);
40
+ });
41
+
42
+ it('puts undated and unparseable items last, in their original order', async () => {
43
+ const raw = JSON.stringify({
44
+ threads: [
45
+ { id: 'nodate' },
46
+ { id: 'empty', internalDateIso: '' },
47
+ { id: 'bad', internalDateIso: 'not-a-date' },
48
+ { id: 'dated', internalDateIso: '2026-08-05T09:00:00-04:00' },
49
+ ],
50
+ nextPageToken: '',
51
+ });
52
+ const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
53
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['dated', 'nodate', 'empty', 'bad']);
54
+ });
55
+
56
+ it('ignores a non-string date field', async () => {
57
+ const raw = JSON.stringify({
58
+ threads: [
59
+ { id: 'weird', internalDateIso: 12345 },
60
+ { id: 'dated', internalDateIso: '2026-08-05T09:00:00-04:00' },
61
+ ],
62
+ nextPageToken: '',
63
+ });
64
+ const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
65
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['dated', 'weird']);
66
+ });
67
+
68
+ it('keeps equal timestamps in their original relative order', async () => {
69
+ const raw = JSON.stringify({
70
+ threads: [
71
+ { id: 'first', internalDateIso: '2026-08-05T09:00:00-04:00' },
72
+ { id: 'second', internalDateIso: '2026-08-05T09:00:00-04:00' },
73
+ ],
74
+ nextPageToken: '',
75
+ });
76
+ const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
77
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['first', 'second']);
78
+ });
79
+
80
+ it('sorts the messages key too', async () => {
81
+ const raw = JSON.stringify({
82
+ messages: [
83
+ { id: 'a', internalDateIso: '2026-08-01T09:00:00-04:00' },
84
+ { id: 'b', internalDateIso: '2026-08-12T09:00:00-04:00' },
85
+ ],
86
+ nextPageToken: '',
87
+ });
88
+ const out = parse(await finalizeGmailSearch(rawTextResult(raw), {
89
+ itemsKey: 'messages', method: 'users.messages.list', query: 'x',
90
+ }));
91
+ expect(out.messages.map((t: { id: string }) => t.id)).toEqual(['b', 'a']);
92
+ });
93
+ });
94
+
95
+ describe('finalizeGmailSearch — truncation metadata', () => {
96
+ const countReply = (ids: number, more: boolean) => JSON.stringify({
97
+ threads: Array.from({ length: ids }, (_, i) => ({ id: `t${i}` })),
98
+ ...(more ? { nextPageToken: 'more' } : {}),
99
+ });
100
+
101
+ it('omits every truncation field when the result set is complete', async () => {
102
+ const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('')), THREAD_OPTS));
103
+ expect(out).not.toHaveProperty('truncated');
104
+ expect(out).not.toHaveProperty('returned');
105
+ expect(out).not.toHaveProperty('totalMatches');
106
+ expect(out).not.toHaveProperty('warning');
107
+ expect(runner.run).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('omits them when gog reports no nextPageToken field at all', async () => {
111
+ const out = parse(await finalizeGmailSearch(rawTextResult('{"threads":[{"id":"a"}]}'), THREAD_OPTS));
112
+ expect(out).not.toHaveProperty('truncated');
113
+ });
114
+
115
+ it('reports an EXACT total when the count probe reaches the end', async () => {
116
+ vi.mocked(runner.run).mockResolvedValue(countReply(21, false));
117
+ const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), {
118
+ itemsKey: 'threads', method: 'users.threads.list', query: 'invoice', account: 'me@x.com',
119
+ }));
120
+ expect(out.truncated).toBe(true);
121
+ expect(out.returned).toBe(3);
122
+ expect(out.totalMatches).toBe(21);
123
+ expect(out).not.toHaveProperty('totalMatchesAtLeast');
124
+ expect(out.warning).toBe(
125
+ 'INCOMPLETE RESULT SET: returned 3 of 21 matches. Do not report an absence of results ' +
126
+ 'based on this response. Page with nextPageToken or narrow the query.',
127
+ );
128
+ });
129
+
130
+ it('reports a LOWER BOUND when the count probe fills its page', async () => {
131
+ vi.mocked(runner.run).mockResolvedValue(countReply(500, true));
132
+ const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
133
+ expect(out.totalMatchesAtLeast).toBe(500);
134
+ expect(out).not.toHaveProperty('totalMatches');
135
+ expect(out.warning).toBe(
136
+ 'INCOMPLETE RESULT SET: returned 3 of at least 500 matches. Do not report an absence of ' +
137
+ 'results based on this response. Page with nextPageToken or narrow the query.',
138
+ );
139
+ });
140
+
141
+ it('counts bare ids for one maximal page, scoped to the matching items key', async () => {
142
+ vi.mocked(runner.run).mockResolvedValue('{"messages":[{"id":"a"}]}');
143
+ const raw = JSON.stringify({ messages: [{ id: 'a' }], nextPageToken: 'tok' });
144
+ await finalizeGmailSearch(rawTextResult(raw), {
145
+ itemsKey: 'messages', method: 'users.messages.list', query: 'invoice', account: 'me@x.com',
146
+ });
147
+ expect(runner.run).toHaveBeenCalledWith(
148
+ ['api', 'call', 'gmail', 'v1', 'users.messages.list',
149
+ `--params=${JSON.stringify({ userId: 'me', q: 'invoice', maxResults: 500, fields: 'messages/id,nextPageToken' })}`],
150
+ { account: 'me@x.com' },
151
+ );
152
+ });
153
+
154
+ it('still warns, without a count, when the count probe fails', async () => {
155
+ vi.mocked(runner.run).mockRejectedValue(new Error('nope'));
156
+ const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
157
+ expect(out.truncated).toBe(true);
158
+ expect(out.returned).toBe(3);
159
+ expect(out).not.toHaveProperty('totalMatches');
160
+ expect(out).not.toHaveProperty('totalMatchesAtLeast');
161
+ expect(out.warning).toBe(
162
+ 'INCOMPLETE RESULT SET: returned 3 matches and MORE EXIST beyond this page. Do not report ' +
163
+ 'an absence of results based on this response. Page with nextPageToken or narrow the query.',
164
+ );
165
+ });
166
+
167
+ it('omits the count when the probe output is not JSON', async () => {
168
+ vi.mocked(runner.run).mockResolvedValue('not json');
169
+ const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
170
+ expect(out).not.toHaveProperty('totalMatches');
171
+ });
172
+
173
+ it('omits the count when the probe returns no items array', async () => {
174
+ vi.mocked(runner.run).mockResolvedValue('{"nextPageToken":"x"}');
175
+ const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
176
+ expect(out).not.toHaveProperty('totalMatches');
177
+ expect(out).not.toHaveProperty('totalMatchesAtLeast');
178
+ });
179
+
180
+ it('treats an empty nextPageToken on the probe as the end of the set', async () => {
181
+ vi.mocked(runner.run).mockResolvedValue('{"threads":[{"id":"a"}],"nextPageToken":""}');
182
+ const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
183
+ expect(out.totalMatches).toBe(1);
184
+ });
185
+
186
+ it('skips the count probe when the query gog ran cannot be reproduced', async () => {
187
+ const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), {
188
+ ...THREAD_OPTS, queryIsExact: false,
189
+ }));
190
+ expect(runner.run).not.toHaveBeenCalled();
191
+ expect(out.truncated).toBe(true);
192
+ expect(out).not.toHaveProperty('totalMatches');
193
+ });
194
+ });
195
+
196
+ describe('finalizeGmailSearch — pass-through', () => {
197
+ it('passes an error result through untouched', async () => {
198
+ const err = errorResult('Error: boom');
199
+ expect(await finalizeGmailSearch(err, THREAD_OPTS)).toBe(err);
200
+ });
201
+
202
+ it('passes non-JSON output through untouched', async () => {
203
+ const text = rawTextResult('No results');
204
+ expect(await finalizeGmailSearch(text, THREAD_OPTS)).toBe(text);
205
+ });
206
+
207
+ it('passes JSON without the expected items array through untouched', async () => {
208
+ const text = rawTextResult('{"somethingElse":1}');
209
+ expect(await finalizeGmailSearch(text, THREAD_OPTS)).toBe(text);
210
+ });
211
+
212
+ it('passes a result with no text content through untouched', async () => {
213
+ const empty = { content: [] } as unknown as Parameters<typeof finalizeGmailSearch>[0];
214
+ expect(await finalizeGmailSearch(empty, THREAD_OPTS)).toBe(empty);
215
+ });
216
+
217
+ it('handles an empty result set without adding truncation fields', async () => {
218
+ const out = parse(await finalizeGmailSearch(rawTextResult('{"threads":[],"nextPageToken":""}'), THREAD_OPTS));
219
+ expect(out.threads).toEqual([]);
220
+ expect(out).not.toHaveProperty('truncated');
221
+ });
222
+ });
223
+
224
+ describe('fetchGmailPages', () => {
225
+ const page = (ids: string[], token?: string) => rawTextResult(JSON.stringify({
226
+ threads: ids.map((id) => ({ id })),
227
+ ...(token === undefined ? {} : { nextPageToken: token }),
228
+ }));
229
+
230
+ it('merges pages and drops the cursor when it reaches the end', async () => {
231
+ const runPage = vi.fn()
232
+ .mockResolvedValueOnce(page(['a'], 'T1'))
233
+ .mockResolvedValueOnce(page(['b'], 'T2'))
234
+ .mockResolvedValueOnce(page(['c']));
235
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 5, undefined)).content[0].text as string);
236
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a', 'b', 'c']);
237
+ expect(out).not.toHaveProperty('nextPageToken');
238
+ expect(runPage.mock.calls.map((c) => c[0])).toEqual([undefined, 'T1', 'T2']);
239
+ });
240
+
241
+ it('treats an empty-string cursor as the end', async () => {
242
+ const runPage = vi.fn().mockResolvedValueOnce(page(['a'], ''));
243
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 5, undefined)).content[0].text as string);
244
+ expect(out).not.toHaveProperty('nextPageToken');
245
+ expect(runPage).toHaveBeenCalledTimes(1);
246
+ });
247
+
248
+ it('keeps the cursor when the page cap is reached first', async () => {
249
+ const runPage = vi.fn()
250
+ .mockResolvedValueOnce(page(['a'], 'T1'))
251
+ .mockResolvedValueOnce(page(['b'], 'T2'));
252
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 2, undefined)).content[0].text as string);
253
+ expect(out.threads).toHaveLength(2);
254
+ expect(out.nextPageToken).toBe('T2');
255
+ expect(runPage).toHaveBeenCalledTimes(2);
256
+ });
257
+
258
+ it('starts from a caller-supplied cursor', async () => {
259
+ const runPage = vi.fn().mockResolvedValue(page(['a']));
260
+ await fetchGmailPages(runPage, 'threads', 3, 'START');
261
+ expect(runPage).toHaveBeenCalledWith('START');
262
+ });
263
+
264
+ it('surfaces a failure on the FIRST page as-is', async () => {
265
+ const err = errorResult('Error: boom');
266
+ const runPage = vi.fn().mockResolvedValue(err);
267
+ expect(await fetchGmailPages(runPage, 'threads', 3, undefined)).toBe(err);
268
+ });
269
+
270
+ it('keeps pages already collected when a LATER page fails, still marked truncated', async () => {
271
+ const runPage = vi.fn()
272
+ .mockResolvedValueOnce(page(['a'], 'T1'))
273
+ .mockResolvedValueOnce(errorResult('Error: boom'));
274
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 3, undefined)).content[0].text as string);
275
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a']);
276
+ expect(out.nextPageToken).toBe('T1');
277
+ });
278
+
279
+ it('bails on output it cannot parse or that has no items array', async () => {
280
+ for (const bad of [rawTextResult('not json'), rawTextResult('"scalar"'), rawTextResult('{"other":1}')]) {
281
+ const runPage = vi.fn().mockResolvedValue(bad);
282
+ expect(await fetchGmailPages(runPage, 'threads', 3, undefined)).toBe(bad);
283
+ }
284
+ });
285
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createTestHarness } from '@chrischall/mcp-utils/test';
3
+ import { BASE_TOOL_REGISTRARS } from '../src/server.js';
4
+
5
+ // The bug this guards against was a NAME mismatch: the cursor was called `page`
6
+ // while every response reports `nextPageToken`, and MCP inputs are zod objects
7
+ // that silently strip unknown keys — so a client guessing the name from the
8
+ // response got page 1 forever, with no error. Prose that steers a caller back
9
+ // to the deprecated alias re-creates exactly that trap, one tool at a time.
10
+
11
+ type ToolDef = { name: string; description?: string; inputSchema?: { properties?: Record<string, unknown> } };
12
+
13
+ async function allTools(): Promise<ToolDef[]> {
14
+ const harness = await createTestHarness((server) => {
15
+ for (const register of BASE_TOOL_REGISTRARS) register(server);
16
+ });
17
+ const { tools } = await harness.client.listTools();
18
+ await harness.close();
19
+ return tools as ToolDef[];
20
+ }
21
+
22
+ describe('page-cursor contract across every base tool', () => {
23
+ it('never tells a caller to pass the cursor as the deprecated `page`', async () => {
24
+ // The alias as its own token: `page` closed immediately, which `pageToken`
25
+ // can never match. A looser pattern matches the correct name's prefix and
26
+ // fails on a description that is already right.
27
+ const offenders = (await allTools())
28
+ .filter((t) => /`page`|\bas page\b/i.test(t.description ?? ''))
29
+ .map((t) => t.name);
30
+ expect(offenders).toEqual([]);
31
+ });
32
+
33
+ it('always offers the `page` alias wherever `pageToken` is accepted', async () => {
34
+ const mismatched = (await allTools())
35
+ .filter((t) => {
36
+ const props = t.inputSchema?.properties ?? {};
37
+ return 'pageToken' in props && !('page' in props);
38
+ })
39
+ .map((t) => t.name);
40
+ expect(mismatched).toEqual([]);
41
+ });
42
+
43
+ it('names the cursor after the response field wherever one is paginated', async () => {
44
+ const tools = await allTools();
45
+ const withCursor = tools.filter((t) => 'pageToken' in (t.inputSchema?.properties ?? {}));
46
+ // Guards the audit itself: if this ever drops to zero the two tests above
47
+ // pass vacuously and the contract stops being checked at all.
48
+ expect(withCursor.length).toBeGreaterThan(0);
49
+ });
50
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
3
+ import {
4
+ stripConsumedPageToken,
5
+ annotateTruncatedList,
6
+ truncationWarning,
7
+ hasMorePages,
8
+ } from '../src/pagination.js';
9
+
10
+ describe('stripConsumedPageToken', () => {
11
+ it('removes an exhausted cursor so the key means "another page exists"', () => {
12
+ expect(stripConsumedPageToken('{"threads":[{"id":"a"}],"nextPageToken":""}'))
13
+ .toBe('{"threads":[{"id":"a"}]}');
14
+ });
15
+
16
+ it('keeps a live cursor untouched, byte for byte', () => {
17
+ const live = '{"threads":[{"id":"a"}],"nextPageToken":"tok"}';
18
+ expect(stripConsumedPageToken(live)).toBe(live);
19
+ });
20
+
21
+ it('preserves --pretty indentation when it has to re-serialize', () => {
22
+ const pretty = '{\n "threads": [],\n "nextPageToken": ""\n}';
23
+ expect(stripConsumedPageToken(pretty)).toBe('{\n "threads": []\n}');
24
+ });
25
+
26
+ it('treats a tab indent as two spaces, matching the timestamp seam', () => {
27
+ expect(stripConsumedPageToken('{\n\t"a": 1,\n\t"nextPageToken": ""\n}'))
28
+ .toBe('{\n "a": 1\n}');
29
+ });
30
+
31
+ it('leaves a payload with no cursor field alone', () => {
32
+ const plain = '{"threads":[]}';
33
+ expect(stripConsumedPageToken(plain)).toBe(plain);
34
+ });
35
+
36
+ it('only touches the top level — a nested cursor is another resource\'s', () => {
37
+ const nested = '{"thread":{"nextPageToken":""}}';
38
+ expect(stripConsumedPageToken(nested)).toBe(nested);
39
+ });
40
+
41
+ it('passes through non-JSON, empty, array and scalar output untouched', () => {
42
+ for (const text of ['No results', '', ' ', '[{"nextPageToken":""}]', '"a string"', '{not json']) {
43
+ expect(stripConsumedPageToken(text)).toBe(text);
44
+ }
45
+ });
46
+
47
+ it('passes through a JSON null without dereferencing it', () => {
48
+ expect(stripConsumedPageToken('null')).toBe('null');
49
+ });
50
+ });
51
+
52
+ describe('truncationWarning', () => {
53
+ it('names an exact total when one is known', () => {
54
+ expect(truncationWarning(3, { total: 21 })).toContain('returned 3 of 21 matches');
55
+ });
56
+
57
+ it('says "at least" for a lower bound', () => {
58
+ expect(truncationWarning(3, { atLeast: 500 })).toContain('returned 3 of at least 500 matches');
59
+ });
60
+
61
+ it('still forbids a negative conclusion when no count is available', () => {
62
+ const w = truncationWarning(3, {});
63
+ expect(w).toContain('returned 3 matches and MORE EXIST beyond this page');
64
+ expect(w).toContain('Do not report an absence of results');
65
+ });
66
+ });
67
+
68
+ describe('hasMorePages', () => {
69
+ it('is true only for a live cursor', () => {
70
+ expect(hasMorePages({ nextPageToken: 'tok' })).toBe(true);
71
+ expect(hasMorePages({ nextPageToken: '' })).toBe(false);
72
+ expect(hasMorePages({})).toBe(false);
73
+ expect(hasMorePages({ nextPageToken: 7 })).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe('annotateTruncatedList', () => {
78
+ it('adds the block when a live cursor remains', () => {
79
+ const out = JSON.parse(annotateTruncatedList(
80
+ rawTextResult('{"events":[{"id":"a"},{"id":"b"}],"nextPageToken":"tok"}'), 'events',
81
+ ).content[0].text as string);
82
+ expect(out.truncated).toBe(true);
83
+ expect(out.returned).toBe(2);
84
+ expect(out.warning).toContain('INCOMPLETE RESULT SET');
85
+ });
86
+
87
+ it('returns the result untouched when the set is complete', () => {
88
+ const complete = rawTextResult('{"events":[{"id":"a"}]}');
89
+ expect(annotateTruncatedList(complete, 'events')).toBe(complete);
90
+ });
91
+
92
+ it('passes errors, non-JSON, missing arrays and empty content through untouched', () => {
93
+ const err = errorResult('boom');
94
+ expect(annotateTruncatedList(err, 'events')).toBe(err);
95
+ const text = rawTextResult('No results');
96
+ expect(annotateTruncatedList(text, 'events')).toBe(text);
97
+ const other = rawTextResult('{"somethingElse":1,"nextPageToken":"tok"}');
98
+ expect(annotateTruncatedList(other, 'events')).toBe(other);
99
+ const empty = { content: [] } as unknown as Parameters<typeof annotateTruncatedList>[0];
100
+ expect(annotateTruncatedList(empty, 'events')).toBe(empty);
101
+ });
102
+ });
@@ -124,6 +124,35 @@ describe('gog_auth_add', () => {
124
124
  );
125
125
  });
126
126
 
127
+ // gog 0.37.0's Connected Sheets reads need bigquery.readonly, which no
128
+ // `services` selection covers. --force-consent is not optional alongside it:
129
+ // Google re-prompts for a NEW scope only when consent is forced, so without
130
+ // it the grant can come back missing the scope AND reporting success.
131
+ it('passes --extra-scopes with --force-consent', async () => {
132
+ vi.mocked(runner.run).mockResolvedValue('Authorization successful');
133
+ const harness = await setupHandlers();
134
+ await harness.callTool('gog_auth_add', {
135
+ email: 'user@gmail.com',
136
+ services: 'sheets',
137
+ extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
138
+ });
139
+ expect(runner.run).toHaveBeenCalledWith(
140
+ ['auth', 'add', 'user@gmail.com', '--services', 'sheets',
141
+ '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly', '--force-consent'],
142
+ { interactive: true, timeout: 300_000 },
143
+ );
144
+ });
145
+
146
+ it('does not force consent when no extra scopes are asked for', async () => {
147
+ vi.mocked(runner.run).mockResolvedValue('Authorization successful');
148
+ const harness = await setupHandlers();
149
+ await harness.callTool('gog_auth_add', { email: 'user@gmail.com' });
150
+ expect(runner.run).toHaveBeenCalledWith(
151
+ ['auth', 'add', 'user@gmail.com', '--services', 'all'],
152
+ { interactive: true, timeout: 300_000 },
153
+ );
154
+ });
155
+
127
156
  it('returns error text on failure', async () => {
128
157
  vi.mocked(runner.run).mockRejectedValue(new Error('Auth cancelled by user'));
129
158
  const harness = await setupHandlers();
@@ -215,6 +244,21 @@ describe('gog_auth_add_url', () => {
215
244
  );
216
245
  });
217
246
 
247
+ it('appends --extra-scopes after the service scopes', async () => {
248
+ vi.mocked(runner.run).mockResolvedValue('{"auth_url":"https://x"}');
249
+ const harness = await setupHandlers();
250
+ await harness.callTool('gog_auth_add_url', {
251
+ email: 'user@gmail.com',
252
+ services: 'sheets',
253
+ extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
254
+ });
255
+ expect(runner.run).toHaveBeenCalledWith(
256
+ ['auth', 'add', 'user@gmail.com', '--remote', '--step', '1', '--services', 'sheets', '--force-consent',
257
+ '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly'],
258
+ { redactMode: 'tokens' },
259
+ );
260
+ });
261
+
218
262
  it('returns error text on failure', async () => {
219
263
  vi.mocked(runner.run).mockRejectedValue(new Error('client not configured'));
220
264
  const harness = await setupHandlers();
@@ -252,6 +296,22 @@ describe('gog_auth_add_complete', () => {
252
296
  );
253
297
  });
254
298
 
299
+ it('carries the same --extra-scopes as step 1', async () => {
300
+ vi.mocked(runner.run).mockResolvedValue('{"stored":true}');
301
+ const harness = await setupHandlers();
302
+ await harness.callTool('gog_auth_add_complete', {
303
+ email: 'user@gmail.com',
304
+ redirectUrl: 'http://127.0.0.1/cb?code=c&state=s',
305
+ services: 'sheets',
306
+ extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
307
+ });
308
+ expect(runner.run).toHaveBeenCalledWith(
309
+ ['auth', 'add', 'user@gmail.com', '--remote', '--step', '2', '--auth-url',
310
+ 'http://127.0.0.1/cb?code=c&state=s', '--services', 'sheets', '--force-consent',
311
+ '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly'],
312
+ );
313
+ });
314
+
255
315
  it('returns error text on failure (e.g. expired state)', async () => {
256
316
  vi.mocked(runner.run).mockRejectedValue(new Error('no matching manual auth state'));
257
317
  const harness = await setupHandlers();
@@ -17,23 +17,50 @@ describe('gog_calendar_events', () => {
17
17
  expect(runner.run).toHaveBeenCalledWith(['calendar', 'events'], { account: undefined });
18
18
  });
19
19
 
20
- it('appends calendarId and filters when provided', async () => {
20
+ // The window flags are pinned as SEPARATE cases on purpose: gog >= 0.36.0
21
+ // (openclaw/gogcli#981) rejects a fixed preset combined with from/to/days,
22
+ // and days combined with to, so one test passing them all at once would
23
+ // assert an arg array gog refuses to run.
24
+ it('appends calendarId and an explicit from/to range', async () => {
21
25
  vi.mocked(runner.run).mockResolvedValue('{}');
22
26
  const harness = await setupHandlers();
23
27
  await harness.callTool('gog_calendar_events', {
24
28
  calendarId: 'primary',
25
29
  from: '2026-01-01',
26
30
  to: '2026-01-31',
27
- today: true,
28
31
  query: 'standup',
29
32
  all: true,
30
33
  });
31
34
  expect(runner.run).toHaveBeenCalledWith(
32
- ['calendar', 'events', 'primary', '--from=2026-01-01', '--to=2026-01-31', '--today', '--query=standup', '--all'],
35
+ ['calendar', 'events', 'primary', '--from=2026-01-01', '--to=2026-01-31', '--query=standup', '--all'],
33
36
  { account: undefined },
34
37
  );
35
38
  });
36
39
 
40
+ it('appends --today on its own', async () => {
41
+ vi.mocked(runner.run).mockResolvedValue('{}');
42
+ const harness = await setupHandlers();
43
+ await harness.callTool('gog_calendar_events', { today: true });
44
+ expect(runner.run).toHaveBeenCalledWith(['calendar', 'events', '--today'], { account: undefined });
45
+ });
46
+
47
+ it('anchors --days at --from when both are given', async () => {
48
+ vi.mocked(runner.run).mockResolvedValue('{}');
49
+ const harness = await setupHandlers();
50
+ await harness.callTool('gog_calendar_events', { from: '2026-09-25', days: 5 });
51
+ expect(runner.run).toHaveBeenCalledWith(
52
+ ['calendar', 'events', '--from=2026-09-25', '--days=5'],
53
+ { account: undefined },
54
+ );
55
+ });
56
+
57
+ it('passes --days alone as a today-anchored window', async () => {
58
+ vi.mocked(runner.run).mockResolvedValue('{}');
59
+ const harness = await setupHandlers();
60
+ await harness.callTool('gog_calendar_events', { days: 7 });
61
+ expect(runner.run).toHaveBeenCalledWith(['calendar', 'events', '--days=7'], { account: undefined });
62
+ });
63
+
37
64
  it('repeats --event-types for each requested type', async () => {
38
65
  vi.mocked(runner.run).mockResolvedValue('{}');
39
66
  const harness = await setupHandlers();
@@ -379,3 +406,54 @@ describe('gog_calendar_run', () => {
379
406
  });
380
407
  });
381
408
 
409
+
410
+ describe('gog_calendar_events — pagination (previously absent entirely)', () => {
411
+ // gog defaults this command to --max=10 and the tool exposed NEITHER max nor
412
+ // a cursor, so a wide date range silently returned 10 events with a live
413
+ // token the caller could not use. Measured live: 2025-01-01..2026-08-01 gave
414
+ // 10 of 12.
415
+ it('passes --max and --page through to gog', async () => {
416
+ vi.mocked(runner.run).mockResolvedValue('{"events":[]}');
417
+ const harness = await setupHandlers();
418
+ await harness.callTool('gog_calendar_events', { max: 100, pageToken: 'CURSOR' });
419
+ const args = vi.mocked(runner.run).mock.calls[0][0] as string[];
420
+ expect(args).toContain('--max=100');
421
+ expect(args).toContain('--page=CURSOR');
422
+ });
423
+
424
+ it('accepts the deprecated page alias', async () => {
425
+ vi.mocked(runner.run).mockResolvedValue('{"events":[]}');
426
+ const harness = await setupHandlers();
427
+ await harness.callTool('gog_calendar_events', { page: 'CURSOR' });
428
+ expect(vi.mocked(runner.run).mock.calls[0][0]).toContain('--page=CURSOR');
429
+ });
430
+
431
+ it('keeps --all meaning ALL CALENDARS, not all pages', async () => {
432
+ vi.mocked(runner.run).mockResolvedValue('{"events":[]}');
433
+ const harness = await setupHandlers();
434
+ await harness.callTool('gog_calendar_events', { all: true });
435
+ expect(vi.mocked(runner.run).mock.calls[0][0]).toContain('--all');
436
+ });
437
+
438
+ it('marks a capped range truncated, with no fabricated total', async () => {
439
+ vi.mocked(runner.run).mockResolvedValue(JSON.stringify({
440
+ events: [{ id: 'e1' }, { id: 'e2' }],
441
+ nextPageToken: 'MORE',
442
+ }));
443
+ const harness = await setupHandlers();
444
+ const out = JSON.parse((await harness.callTool('gog_calendar_events',
445
+ { from: '2025-01-01', to: '2026-08-01' })).content[0].text as string);
446
+ expect(out.truncated).toBe(true);
447
+ expect(out.returned).toBe(2);
448
+ expect(out).not.toHaveProperty('totalMatches');
449
+ expect(out.warning).toContain('INCOMPLETE RESULT SET: returned 2 matches and MORE EXIST');
450
+ });
451
+
452
+ it('leaves a complete range unannotated', async () => {
453
+ vi.mocked(runner.run).mockResolvedValue('{"events":[{"id":"e1"}],"nextPageToken":""}');
454
+ const harness = await setupHandlers();
455
+ const out = JSON.parse((await harness.callTool('gog_calendar_events', {})).content[0].text as string);
456
+ expect(out).not.toHaveProperty('truncated');
457
+ expect(out).not.toHaveProperty('nextPageToken');
458
+ });
459
+ });