gogcli-mcp 2.24.0 → 2.26.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.
@@ -8,11 +8,13 @@ import type { Spawner } from '../src/runner.js';
8
8
  // its own file because the mock would defeat the byte-level round-trip
9
9
  // assertions in runner-file-args.test.ts.
10
10
  const mkdtemp = vi.fn(async () => '/tmp/gogcli-mcp-fake');
11
+ const mkdir = vi.fn(async () => undefined);
11
12
  const writeFile = vi.fn(async () => {});
12
13
  const rm = vi.fn(async () => {});
13
14
 
14
15
  vi.mock('node:fs/promises', () => ({
15
16
  mkdtemp: (...args: unknown[]) => mkdtemp(...(args as [])),
17
+ mkdir: (...args: unknown[]) => mkdir(...(args as [])),
16
18
  writeFile: (...args: unknown[]) => writeFile(...(args as [])),
17
19
  rm: (...args: unknown[]) => rm(...(args as [])),
18
20
  }));
@@ -36,9 +38,11 @@ const fileArg = { kind: 'file', flag: 'body-file', contents: 'payload' } as cons
36
38
  describe('temp-file materialization failures', () => {
37
39
  beforeEach(() => {
38
40
  mkdtemp.mockClear();
41
+ mkdir.mockClear();
39
42
  writeFile.mockClear();
40
43
  rm.mockClear();
41
44
  rm.mockImplementation(async () => {});
45
+ mkdir.mockImplementation(async () => undefined);
42
46
  writeFile.mockImplementation(async () => {});
43
47
  });
44
48
 
@@ -83,12 +87,53 @@ describe('temp-file materialization failures', () => {
83
87
  await expect(run(['gmail', 'send', fileArg], { spawner })).rejects.toThrow('gog: invalid draft');
84
88
  });
85
89
 
86
- it('writes the payload with mode 0600 and utf8 encoding', async () => {
90
+ it('writes the payload as owner-only utf8 bytes', async () => {
87
91
  await run(['gmail', 'send', fileArg], { spawner: okSpawner() });
88
92
  expect(writeFile).toHaveBeenCalledWith(
89
93
  expect.stringContaining('body-file.txt'),
90
- 'payload',
91
- { encoding: 'utf8', mode: 0o600 },
94
+ Buffer.from('payload', 'utf8'),
95
+ { mode: 0o600 },
92
96
  );
97
+ // Each payload lands in its own numbered subdirectory of the temp dir, so a
98
+ // caller-chosen basename can never clobber another payload's.
99
+ expect(mkdir).toHaveBeenCalledWith('/tmp/gogcli-mcp-fake/0', { recursive: true, mode: 0o700 });
100
+ });
101
+
102
+ it('decodes a base64 payload to real bytes and honours a caller filename', async () => {
103
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
104
+ await run([
105
+ 'gmail', 'send',
106
+ { kind: 'file', flag: 'attach', contents: png.toString('base64'), encoding: 'base64', filename: 'Screenshot 2026-06-13 152500.png' },
107
+ ], { spawner: okSpawner() });
108
+ expect(writeFile).toHaveBeenCalledWith(
109
+ '/tmp/gogcli-mcp-fake/0/Screenshot 2026-06-13 152500.png',
110
+ png,
111
+ { mode: 0o600 },
112
+ );
113
+ });
114
+
115
+ it('gives each file arg its own subdirectory so identical basenames survive', async () => {
116
+ const spawner = okSpawner();
117
+ await run([
118
+ 'gmail', 'send',
119
+ { kind: 'file', flag: 'attach', contents: 'YQ==', encoding: 'base64', filename: 'chart.png' },
120
+ { kind: 'file', flag: 'attach', contents: 'Yg==', encoding: 'base64', filename: 'chart.png' },
121
+ ], { spawner });
122
+ const written = writeFile.mock.calls.map((c) => c[0]);
123
+ expect(written).toEqual(['/tmp/gogcli-mcp-fake/0/chart.png', '/tmp/gogcli-mcp-fake/1/chart.png']);
124
+ // Both survive as distinct --attach values rather than one clobbering the other.
125
+ const argv = (spawner as unknown as { mock: { calls: [string, string[]][] } }).mock.calls[0][1];
126
+ expect(argv.filter((a) => a.startsWith('--attach='))).toHaveLength(2);
127
+ });
128
+
129
+ it('emits a positional file arg as a bare path, not --flag=path', async () => {
130
+ const spawner = okSpawner();
131
+ await run([
132
+ 'drive', 'upload',
133
+ { kind: 'file', flag: 'localPath', contents: 'YQ==', encoding: 'base64', filename: 'notes.md', positional: true },
134
+ ], { spawner });
135
+ const argv = (spawner as unknown as { mock: { calls: [string, string[]][] } }).mock.calls[0][1];
136
+ expect(argv).toContain('/tmp/gogcli-mcp-fake/0/notes.md');
137
+ expect(argv.some((a) => a.startsWith('--localPath='))).toBe(false);
93
138
  });
94
139
  });
@@ -592,6 +592,132 @@ describe('run', () => {
592
592
  expect(tokensOnly).not.toContain('[REDACTED]');
593
593
  });
594
594
 
595
+ // ==========================================================================
596
+ // BASE64 PAYLOAD SURVIVAL — the "Invalid Base64 string" defect.
597
+ //
598
+ // `1//` is spelled entirely in the base64 alphabet, so the refresh-token
599
+ // pattern used to match inside attachment bytes and eat forward to the next
600
+ // `+` or `/`. A 72 KiB PNG is ~97k base64 chars, giving ~0.37 expected `1//`
601
+ // runs — so roughly a THIRD of all attachments came back corrupt, and the
602
+ // client rejected them with an MCP -32602 "Invalid Base64 string".
603
+ //
604
+ // It looked filename-dependent because it is content-dependent and therefore
605
+ // uncorrelated with anything visible. These tests pin the real variable.
606
+ // ==========================================================================
607
+ const isValidBase64 = (s: string): boolean => Buffer.from(s, 'base64').toString('base64') === s;
608
+
609
+ it('leaves a base64 payload containing the refresh-token spelling intact', async () => {
610
+ // `1//` sitting mid-blob, preceded by base64 characters — the exact shape
611
+ // that used to be deleted.
612
+ const payload = `AAAA1//abcdefghijklmnop${'QRSTuvwx'.repeat(4)}`;
613
+ expect(payload).toContain('1//');
614
+ const spawner = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
615
+ const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner });
616
+ expect(JSON.parse(out).contentBase64).toBe(payload);
617
+ expect(out).not.toContain('[REDACTED]');
618
+ });
619
+
620
+ it('keeps every generated attachment payload valid base64 (the ~30% failure)', async () => {
621
+ // 200 payloads at the reported sizes. Before the fix ~30% of these came back
622
+ // undecodable; the assertion is that ALL of them survive, not most.
623
+ for (let i = 0; i < 200; i += 1) {
624
+ const bytes = Buffer.alloc(4096);
625
+ // Deterministic filler that still produces `1//` runs at the natural rate:
626
+ // a counter-driven byte pattern, seeded differently per iteration.
627
+ for (let b = 0; b < bytes.length; b += 1) bytes[b] = (b * 31 + i * 7) % 256;
628
+ const payload = bytes.toString('base64');
629
+ const spawner = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
630
+ const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner });
631
+ const got = JSON.parse(out).contentBase64 as string;
632
+ expect(isValidBase64(got)).toBe(true);
633
+ expect(got).toBe(payload);
634
+ }
635
+ });
636
+
637
+ // The left-boundary anchor must not narrow detection. Every character NOT in
638
+ // the standard base64 alphabet is a delimiter a real token turns up after, and
639
+ // `=` is the one that matters most: the form-encoded spelling is not covered by
640
+ // the shared redactor's query-param rule, which requires a preceding `?`/`&`.
641
+ it.each([
642
+ ['refresh_token=1//0eFORM-ENCODED-LEAK', '1//0eFORM-ENCODED-LEAK'],
643
+ ['access_token=ya29.a0FORM-ENCODED-LEAK', 'ya29.a0FORM-ENCODED-LEAK'],
644
+ ['grant:1//0eCOLON-LEAK', '1//0eCOLON-LEAK'],
645
+ ['[1//0eBRACKET-LEAK]', '1//0eBRACKET-LEAK'],
646
+ ['token is ya29.a0SPACE-LEAK', 'ya29.a0SPACE-LEAK'],
647
+ ])('redacts a token delimited by %j', async (stdout, secret) => {
648
+ const spawner = makeSpawner(0, stdout, '');
649
+ const out = await run(['auth', 'list'], { spawner });
650
+ expect(out).not.toContain(secret);
651
+ expect(out).toContain('[REDACTED]');
652
+ });
653
+
654
+ it("redactMode 'tokens' also catches the form-encoded spelling", async () => {
655
+ // This path runs ONLY redactGoogleTokens, so the anchor is the whole defence.
656
+ const spawner = makeSpawner(0, 'refresh_token=1//0eTOKENS-MODE-LEAK', '');
657
+ const out = await run(['auth', 'add'], { spawner, redactMode: 'tokens' });
658
+ expect(out).not.toContain('1//0eTOKENS-MODE-LEAK');
659
+ expect(out).toContain('[REDACTED]');
660
+ });
661
+
662
+ it('still redacts a real refresh token, which is never welded to base64', async () => {
663
+ // The anchor must not have bought base64 survival at the cost of detection:
664
+ // a genuine token is always delimited (quote, space, `=`, `:`), so it still
665
+ // matches.
666
+ const spawner = makeSpawner(0, '{"refresh_token":"1//0eREAL-REFRESH-TOKEN"}', '');
667
+ const out = await run(['auth', 'list'], { spawner });
668
+ expect(out).not.toContain('1//0eREAL-REFRESH-TOKEN');
669
+ expect(out).toContain('[REDACTED]');
670
+ });
671
+
672
+ it('opaqueFields exempts a named blob from redaction it would otherwise fail', async () => {
673
+ // A payload that spells a Google API key by chance — still possible after
674
+ // the boundary anchor, because AIza… needs no delimiter. The field
675
+ // exemption is what covers this class rather than one pattern.
676
+ // `/` supplies the word boundary AIza… needs, and every character here is
677
+ // in the base64 alphabet — so this is a payload a real file can produce.
678
+ const payload = `AAAA/AIza${'B'.repeat(35)}/CCC`;
679
+ expect(Buffer.from(payload, 'base64').toString('base64')).toBe(payload); // genuinely valid base64
680
+ const spawner = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
681
+ const bare = await run(['gmail', 'attachment', 'm1', '0'], { spawner });
682
+ expect(bare).toContain('[REDACTED]'); // unprotected, the shared redactor hits it
683
+
684
+ const spawner2 = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
685
+ const guarded = await run(['gmail', 'attachment', 'm1', '0'], {
686
+ spawner: spawner2,
687
+ opaqueFields: ['contentBase64'],
688
+ });
689
+ expect(JSON.parse(guarded).contentBase64).toBe(payload);
690
+ });
691
+
692
+ it('opaqueFields still redacts prose OUTSIDE the exempt field', async () => {
693
+ // The exemption is per-field, not per-response: a token in a sibling field
694
+ // must still be stripped.
695
+ const stdout = JSON.stringify({
696
+ contentBase64: 'QUJDREVGR0hJSktMTU5PUFFSU1Q=',
697
+ note: 'refreshed with 1//0eLEAK-IN-PROSE',
698
+ });
699
+ const spawner = makeSpawner(0, stdout, '');
700
+ const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner, opaqueFields: ['contentBase64'] });
701
+ expect(out).not.toContain('1//0eLEAK-IN-PROSE');
702
+ expect(JSON.parse(out).contentBase64).toBe('QUJDREVGR0hJSktMTU5PUFFSU1Q=');
703
+ });
704
+
705
+ it('opaqueFields does not exempt a field carrying prose rather than a blob', async () => {
706
+ // Only an all-base64 value qualifies, so naming a field cannot be used to
707
+ // smuggle a credential through in a sentence.
708
+ const stdout = JSON.stringify({ contentBase64: 'token is 1//0eSMUGGLED-TOKEN here' });
709
+ const spawner = makeSpawner(0, stdout, '');
710
+ const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner, opaqueFields: ['contentBase64'] });
711
+ expect(out).not.toContain('1//0eSMUGGLED-TOKEN');
712
+ });
713
+
714
+ it('opaqueFields never exempts anything on the ERROR path', async () => {
715
+ const spawner = makeSpawner(1, '', 'failed for 1//0eERROR-PATH-LEAK');
716
+ await expect(
717
+ run(['gmail', 'attachment', 'm1', '0'], { spawner, opaqueFields: ['contentBase64'] }),
718
+ ).rejects.toThrow(/\[REDACTED\]/);
719
+ });
720
+
595
721
  it("redactMode 'tokens' still strips real Google tokens", async () => {
596
722
  const leak = 'url with token ya29.a0Ad52N3-STEP-LEAK and refresh 1//0eSTEP-REFRESH-LEAK';
597
723
  const spawner = makeSpawner(0, leak, '');
@@ -14,8 +14,10 @@ describe('BASE_TOOL_REGISTRARS', () => {
14
14
  // One representative tool per service registrar, in registrar order.
15
15
  for (const expected of [
16
16
  'gog_api_list',
17
+ 'gog_appscript_get',
17
18
  'gog_auth_list',
18
19
  'gog_calendar_events',
20
+ 'gog_chat_spaces_list',
19
21
  'gog_classroom_courses_list',
20
22
  'gog_contacts_list',
21
23
  'gog_docs_cat',
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { registerAppScriptTools } from '../../src/tools/appscript.js';
3
+ import * as runner from '../../src/runner.js';
4
+ import { createTestHarness } from '@chrischall/mcp-utils/test';
5
+
6
+ vi.mock('../../src/runner.js');
7
+
8
+ const setupHandlers = () => createTestHarness(registerAppScriptTools);
9
+
10
+ beforeEach(() => vi.clearAllMocks());
11
+
12
+ describe('gog_appscript_get', () => {
13
+ it('gets project metadata', async () => {
14
+ vi.mocked(runner.run).mockResolvedValue('{}');
15
+ const harness = await setupHandlers();
16
+ await harness.callTool('gog_appscript_get', { scriptId: 'S1' });
17
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'get', 'S1'], { account: undefined });
18
+ });
19
+
20
+ it('returns error text on failure', async () => {
21
+ vi.mocked(runner.run).mockRejectedValue(new Error('Get failed'));
22
+ const harness = await setupHandlers();
23
+ const result = await harness.callTool('gog_appscript_get', { scriptId: 'S1' });
24
+ expect(result.content[0].text).toBe('Error: Get failed');
25
+ });
26
+ });
27
+
28
+ describe('gog_appscript_content', () => {
29
+ it('reads the project source', async () => {
30
+ vi.mocked(runner.run).mockResolvedValue('{}');
31
+ const harness = await setupHandlers();
32
+ await harness.callTool('gog_appscript_content', { scriptId: 'S1', account: 'me@x.com' });
33
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'content', 'S1'], { account: 'me@x.com' });
34
+ });
35
+ });
36
+
37
+ describe('gog_appscript_pull', () => {
38
+ it('pulls into a directory', async () => {
39
+ vi.mocked(runner.run).mockResolvedValue('{}');
40
+ const harness = await setupHandlers();
41
+ await harness.callTool('gog_appscript_pull', { scriptId: 'S1', dir: '/tmp/proj' });
42
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'pull', 'S1', '/tmp/proj'], { account: undefined });
43
+ });
44
+
45
+ it('passes --overwrite', async () => {
46
+ vi.mocked(runner.run).mockResolvedValue('{}');
47
+ const harness = await setupHandlers();
48
+ await harness.callTool('gog_appscript_pull', { scriptId: 'S1', dir: '/tmp/proj', overwrite: true });
49
+ expect(runner.run).toHaveBeenCalledWith(
50
+ ['appscript', 'pull', 'S1', '/tmp/proj', '--overwrite'],
51
+ { account: undefined },
52
+ );
53
+ });
54
+
55
+ // The directory resolves where gog runs, which is a different machine on the
56
+ // hosted connector. A caller who does not know that gets a "success" whose
57
+ // files they cannot reach.
58
+ it('says in its description where the directory resolves', async () => {
59
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
60
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
61
+ const configs = new Map<string, { description?: string }>();
62
+ vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
63
+ configs.set(name, config as { description?: string });
64
+ return undefined as never;
65
+ });
66
+ registerAppScriptTools(server);
67
+ const desc = configs.get('gog_appscript_pull')?.description ?? '';
68
+ expect(desc).toContain('RESOLVED WHERE GOG RUNS');
69
+ expect(desc).toMatch(/gog_appscript_content/);
70
+ });
71
+ });
72
+
73
+ describe('gog_appscript_create', () => {
74
+ it('creates a standalone project', async () => {
75
+ vi.mocked(runner.run).mockResolvedValue('{}');
76
+ const harness = await setupHandlers();
77
+ await harness.callTool('gog_appscript_create', { title: 'Helpers' });
78
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'create', '--title=Helpers'], { account: undefined });
79
+ });
80
+
81
+ it('binds the project to a Drive file', async () => {
82
+ vi.mocked(runner.run).mockResolvedValue('{}');
83
+ const harness = await setupHandlers();
84
+ await harness.callTool('gog_appscript_create', { title: 'Bound', parentId: 'FILE1' });
85
+ expect(runner.run).toHaveBeenCalledWith(
86
+ ['appscript', 'create', '--title=Bound', '--parent-id=FILE1'],
87
+ { account: undefined },
88
+ );
89
+ });
90
+ });
91
+
92
+ describe('gog_appscript_deployments', () => {
93
+ it('lists deployments with pagination', async () => {
94
+ vi.mocked(runner.run).mockResolvedValue('{}');
95
+ const harness = await setupHandlers();
96
+ await harness.callTool('gog_appscript_deployments', { scriptId: 'S1', max: 10, pageToken: 'tok' });
97
+ expect(runner.run).toHaveBeenCalledWith(
98
+ ['appscript', 'deployments', 'S1', '--max=10', '--page=tok'],
99
+ { account: undefined },
100
+ );
101
+ });
102
+ });
103
+
104
+ describe('gog_appscript_versions', () => {
105
+ it('lists versions', async () => {
106
+ vi.mocked(runner.run).mockResolvedValue('{}');
107
+ const harness = await setupHandlers();
108
+ await harness.callTool('gog_appscript_versions', { scriptId: 'S1', all: true });
109
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'versions', 'S1', '--all'], { account: undefined });
110
+ });
111
+ });
112
+
113
+ describe('gog_appscript_run_function', () => {
114
+ it('runs a deployed function', async () => {
115
+ vi.mocked(runner.run).mockResolvedValue('{}');
116
+ const harness = await setupHandlers();
117
+ await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' });
118
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'run', 'S1', 'doWork'], { account: undefined });
119
+ });
120
+
121
+ it('passes params and --dev-mode', async () => {
122
+ vi.mocked(runner.run).mockResolvedValue('{}');
123
+ const harness = await setupHandlers();
124
+ await harness.callTool('gog_appscript_run_function', {
125
+ scriptId: 'S1', functionName: 'doWork', params: '["a",1]', devMode: true,
126
+ });
127
+ expect(runner.run).toHaveBeenCalledWith(
128
+ ['appscript', 'run', 'S1', 'doWork', '--params=["a",1]', '--dev-mode'],
129
+ { account: undefined },
130
+ );
131
+ });
132
+
133
+ it('rejects params that are not a JSON array before spawning gog', async () => {
134
+ const harness = await setupHandlers();
135
+ const result = await harness.callTool('gog_appscript_run_function', {
136
+ scriptId: 'S1', functionName: 'doWork', params: '{"a":1}',
137
+ });
138
+ expect(result.isError).toBe(true);
139
+ expect(runner.run).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it('rejects params that are not JSON at all', async () => {
143
+ const harness = await setupHandlers();
144
+ const result = await harness.callTool('gog_appscript_run_function', {
145
+ scriptId: 'S1', functionName: 'doWork', params: 'a,1',
146
+ });
147
+ expect(result.isError).toBe(true);
148
+ expect(runner.run).not.toHaveBeenCalled();
149
+ });
150
+ });
151
+
152
+ describe('gog_appscript_run', () => {
153
+ it('passes the subcommand and args through', async () => {
154
+ vi.mocked(runner.run).mockResolvedValue('{}');
155
+ const harness = await setupHandlers();
156
+ await harness.callTool('gog_appscript_run', { subcommand: 'get', args: ['S1'] });
157
+ expect(runner.run).toHaveBeenCalledWith(['appscript', 'get', 'S1'], { account: undefined });
158
+ });
159
+ });
@@ -457,3 +457,124 @@ describe('gog_calendar_events — pagination (previously absent entirely)', () =
457
457
  expect(out).not.toHaveProperty('nextPageToken');
458
458
  });
459
459
  });
460
+
461
+ // Reminders (gog >= 0.38.0 for --no-reminders, openclaw/gogcli#1002/#1016).
462
+ // Three distinct states share these two params and gog spells each one
463
+ // differently, so each is pinned separately: custom overrides, "no reminders at
464
+ // all", and — on update only — "go back to whatever the calendar says", which
465
+ // is an EMPTY --reminder rather than a flag of its own. All three were verified
466
+ // against a real gog 0.38.0 with --dry-run.
467
+ describe('gog_calendar_create reminders', () => {
468
+ it('passes each reminder as its own repeated flag', async () => {
469
+ vi.mocked(runner.run).mockResolvedValue('{}');
470
+ const harness = await setupHandlers();
471
+ await harness.callTool('gog_calendar_create', {
472
+ calendarId: 'primary',
473
+ summary: 'Standup',
474
+ from: '2026-04-14T09:00:00Z',
475
+ to: '2026-04-14T09:30:00Z',
476
+ reminders: ['popup:30m', 'email:1d'],
477
+ });
478
+ expect(runner.run).toHaveBeenCalledWith(
479
+ ['calendar', 'create', 'primary', '--summary=Standup', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
480
+ '--reminder=popup:30m', '--reminder=email:1d'],
481
+ { account: undefined },
482
+ );
483
+ });
484
+
485
+ it('passes --no-reminders', async () => {
486
+ vi.mocked(runner.run).mockResolvedValue('{}');
487
+ const harness = await setupHandlers();
488
+ await harness.callTool('gog_calendar_create', {
489
+ calendarId: 'primary',
490
+ summary: 'Quiet',
491
+ from: '2026-04-14T09:00:00Z',
492
+ to: '2026-04-14T09:30:00Z',
493
+ noReminders: true,
494
+ });
495
+ expect(runner.run).toHaveBeenCalledWith(
496
+ ['calendar', 'create', 'primary', '--summary=Quiet', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
497
+ '--no-reminders'],
498
+ { account: undefined },
499
+ );
500
+ });
501
+
502
+ it('rejects reminders and noReminders together, as gog does', async () => {
503
+ const harness = await setupHandlers();
504
+ const result = await harness.callTool('gog_calendar_create', {
505
+ calendarId: 'primary',
506
+ summary: 'Both',
507
+ from: '2026-04-14T09:00:00Z',
508
+ to: '2026-04-14T09:30:00Z',
509
+ reminders: ['popup:10m'],
510
+ noReminders: true,
511
+ });
512
+ expect(result.isError).toBe(true);
513
+ expect(runner.run).not.toHaveBeenCalled();
514
+ });
515
+
516
+ it('rejects more than the five reminders Google allows', async () => {
517
+ const harness = await setupHandlers();
518
+ const result = await harness.callTool('gog_calendar_create', {
519
+ calendarId: 'primary',
520
+ summary: 'Too many',
521
+ from: '2026-04-14T09:00:00Z',
522
+ to: '2026-04-14T09:30:00Z',
523
+ reminders: ['popup:1m', 'popup:2m', 'popup:3m', 'popup:4m', 'popup:5m', 'popup:6m'],
524
+ });
525
+ expect(result.isError).toBe(true);
526
+ expect(runner.run).not.toHaveBeenCalled();
527
+ });
528
+ });
529
+
530
+ describe('gog_calendar_update reminders', () => {
531
+ it('replaces the overrides', async () => {
532
+ vi.mocked(runner.run).mockResolvedValue('{}');
533
+ const harness = await setupHandlers();
534
+ await harness.callTool('gog_calendar_update', {
535
+ calendarId: 'primary', eventId: 'evt1', reminders: ['popup:15m'],
536
+ });
537
+ expect(runner.run).toHaveBeenCalledWith(
538
+ ['calendar', 'update', 'primary', 'evt1', '--reminder=popup:15m'],
539
+ { account: undefined },
540
+ );
541
+ });
542
+
543
+ // Verified live: `calendar update … --reminder= --dry-run` patches
544
+ // reminders.useDefault=true with overrides cleared.
545
+ it('restores the calendar defaults with an empty reminder list', async () => {
546
+ vi.mocked(runner.run).mockResolvedValue('{}');
547
+ const harness = await setupHandlers();
548
+ await harness.callTool('gog_calendar_update', {
549
+ calendarId: 'primary', eventId: 'evt1', reminders: [],
550
+ });
551
+ expect(runner.run).toHaveBeenCalledWith(
552
+ ['calendar', 'update', 'primary', 'evt1', '--reminder='],
553
+ { account: undefined },
554
+ );
555
+ });
556
+
557
+ it('turns every reminder off', async () => {
558
+ vi.mocked(runner.run).mockResolvedValue('{}');
559
+ const harness = await setupHandlers();
560
+ await harness.callTool('gog_calendar_update', {
561
+ calendarId: 'primary', eventId: 'evt1', noReminders: true,
562
+ });
563
+ expect(runner.run).toHaveBeenCalledWith(
564
+ ['calendar', 'update', 'primary', 'evt1', '--no-reminders'],
565
+ { account: undefined },
566
+ );
567
+ });
568
+
569
+ it('leaves reminders alone when neither param is given', async () => {
570
+ vi.mocked(runner.run).mockResolvedValue('{}');
571
+ const harness = await setupHandlers();
572
+ await harness.callTool('gog_calendar_update', {
573
+ calendarId: 'primary', eventId: 'evt1', summary: 'Renamed',
574
+ });
575
+ expect(runner.run).toHaveBeenCalledWith(
576
+ ['calendar', 'update', 'primary', 'evt1', '--summary=Renamed'],
577
+ { account: undefined },
578
+ );
579
+ });
580
+ });