gogcli-mcp 4.5.0 → 4.5.2

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,447 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createTestHarness } from '@chrischall/mcp-utils/test';
3
+ import type { McpServer, ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
4
+ import * as runner from '../../src/runner.js';
5
+ import { pos } from '../../src/argv.js';
6
+ import { resetConfirmTokenState } from '../../src/send-confirm-token.js';
7
+ import { registerChatTools } from '../../src/tools/chat.js';
8
+ import { registerAppScriptTools, projectSnapshot } from '../../src/tools/appscript.js';
9
+ import { registerClassroomTools, readCoursework, readSubmission, studentLabel } from '../../src/tools/classroom.js';
10
+ import { registerCalendarTools, eventSnapshot } from '../../src/tools/calendar.js';
11
+ import { registerDriveTools, commentMentions, commentSnapshot } from '../../src/tools/drive.js';
12
+
13
+ vi.mock('../../src/runner.js');
14
+
15
+ // ============================================================================
16
+ // The dispatch rail, second wave (fleet audit 2026-09-24 SEC-6): tools that
17
+ // notify people, grant access, run code under the account's authority or
18
+ // delete for good. Same three ways as dispatch-gates.test.ts — a client that
19
+ // can be prompted, one that cannot (refusal + hint), and the confirmToken
20
+ // fallback — plus each tool's "nothing to ask about" path stays unprompted.
21
+ // ============================================================================
22
+
23
+ const ORIGINAL_ENV = { ...process.env };
24
+
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ process.env = { ...ORIGINAL_ENV };
28
+ delete process.env.MCP_CONFIRM_MODE;
29
+ delete process.env.MCP_CONFIRM_TTL_SECONDS;
30
+ delete process.env.MCP_CONFIRM_SECRET;
31
+ process.env.GOG_ACCOUNT = 'me@example.com';
32
+ resetConfirmTokenState();
33
+ });
34
+
35
+ afterEach(() => { process.env = ORIGINAL_ENV; });
36
+
37
+ type Register = (server: McpServer) => void;
38
+ const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
39
+
40
+ async function prompted(register: Register, answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
41
+ const seen: ElicitRequest[] = [];
42
+ const harness = await createTestHarness(register, { elicitation: async (r) => { seen.push(r); return answer; } });
43
+ const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
44
+ return { harness, seen, details };
45
+ }
46
+ const unprompted = (register: Register) => createTestHarness(register);
47
+
48
+ const callsTo = (...words: string[]) => vi.mocked(runner.run).mock.calls.filter(([args]) =>
49
+ words.every((w, i) => args[i] === w));
50
+
51
+ /** Answer reads with `reads[<joined command words>]`, everything else with `{}`; a `failing` prefix rejects. */
52
+ function stubReads(reads: Record<string, string>, failing?: string) {
53
+ vi.mocked(runner.run).mockImplementation(async (args) => {
54
+ const key = (args as unknown[]).filter((a) => typeof a === 'string' && !a.startsWith('--')).join(' ');
55
+ if (failing && key.startsWith(failing)) throw new Error(`${failing} failed`);
56
+ for (const [prefix, out] of Object.entries(reads)) if (key.startsWith(prefix)) return out;
57
+ return '{"ok":true}';
58
+ });
59
+ }
60
+
61
+ describe('gog_chat_spaces_create', () => {
62
+ it('creates a member-less space without asking', async () => {
63
+ stubReads({});
64
+ const { harness, seen } = await prompted(registerChatTools);
65
+ await harness.callTool('gog_chat_spaces_create', { displayName: 'Solo' });
66
+ expect(seen).toHaveLength(0);
67
+ expect(callsTo('chat', 'spaces', 'create')).toHaveLength(1);
68
+ });
69
+
70
+ it('asks with the name and members, then creates once accepted', async () => {
71
+ stubReads({});
72
+ const { harness, details } = await prompted(registerChatTools);
73
+ await harness.callTool('gog_chat_spaces_create', { displayName: 'Launch', members: ['a@example.com', 'users/123'] });
74
+ expect(details()).toEqual({ displayName: 'Launch', members: ['a@example.com', 'users/123'] });
75
+ expect(callsTo('chat', 'spaces', 'create')[0]![0]).toEqual(
76
+ ['chat', 'spaces', 'create', pos('Launch'), '--member=a@example.com', '--member=users/123']);
77
+ });
78
+
79
+ it('creates nothing when the user declines', async () => {
80
+ const { harness } = await prompted(registerChatTools, { action: 'decline' });
81
+ const r = json(await harness.callTool('gog_chat_spaces_create', { displayName: 'Launch', members: ['a@example.com'] }));
82
+ expect(r).toMatchObject({ confirmed: false, cancelled: true, action: 'chat.space-create' });
83
+ expect(runner.run).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('refuses a client that cannot be prompted, pointing at a member-less create', async () => {
87
+ process.env.MCP_CONFIRM_MODE = 'refuse';
88
+ const harness = await unprompted(registerChatTools);
89
+ const r = json(await harness.callTool('gog_chat_spaces_create', { displayName: 'Launch', members: ['a@example.com'] }));
90
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'chat.space-create' });
91
+ expect(r.note).toMatch(/without members/);
92
+ expect(runner.run).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('token fallback: previews the members, creates on phase 2, refuses a changed list', async () => {
96
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
97
+ stubReads({});
98
+ const harness = await unprompted(registerChatTools);
99
+ const args = { displayName: 'Launch', members: ['a@example.com'] };
100
+ const p1 = json(await harness.callTool('gog_chat_spaces_create', args));
101
+ expect(p1).toMatchObject({ status: 'confirmation-required', preview: { displayName: 'Launch', members: ['a@example.com'] } });
102
+ expect(json(await harness.callTool('gog_chat_spaces_create', { ...args, members: ['a@example.com', 'b@example.com'], confirmToken: p1.confirmToken })))
103
+ .toMatchObject({ error: 'DRAFT_CHANGED' });
104
+ await harness.callTool('gog_chat_spaces_create', { ...args, confirmToken: p1.confirmToken });
105
+ expect(callsTo('chat', 'spaces', 'create')).toHaveLength(1);
106
+ });
107
+ });
108
+
109
+ describe('gog_appscript_run_function', () => {
110
+ const PROJECT = JSON.stringify({ project: { scriptId: 'S1', title: 'Payroll sync' }, editor_url: 'https://script.google.com/d/S1/edit' });
111
+
112
+ it('reads the project and asks, naming it, the function and its arguments', async () => {
113
+ stubReads({ 'appscript get': PROJECT });
114
+ const { harness, details } = await prompted(registerAppScriptTools);
115
+ await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork', params: '["a", 1]' });
116
+ expect(details()).toEqual({
117
+ project: { scriptId: 'S1', title: 'Payroll sync' },
118
+ functionName: 'doWork',
119
+ params: ['a', 1],
120
+ devMode: false,
121
+ runsAs: 'me@example.com',
122
+ });
123
+ expect(callsTo('appscript', 'run')[0]![0]).toEqual(['appscript', 'run', pos('S1'), pos('doWork'), '--params=["a", 1]']);
124
+ });
125
+
126
+ it('still rejects malformed params before any gog call', async () => {
127
+ const { harness, seen } = await prompted(registerAppScriptTools);
128
+ const r = await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork', params: '{"a":1}' });
129
+ expect(r.isError).toBe(true);
130
+ expect(seen).toHaveLength(0);
131
+ expect(runner.run).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it('returns a failed project read without running anything', async () => {
135
+ stubReads({}, 'appscript get');
136
+ const { harness, seen } = await prompted(registerAppScriptTools);
137
+ expect((await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' })).isError).toBe(true);
138
+ expect(seen).toHaveLength(0);
139
+ expect(callsTo('appscript', 'run')).toHaveLength(0);
140
+ });
141
+
142
+ it('refuses a client that cannot be prompted', async () => {
143
+ process.env.MCP_CONFIRM_MODE = 'refuse';
144
+ stubReads({ 'appscript get': PROJECT });
145
+ const harness = await unprompted(registerAppScriptTools);
146
+ const r = json(await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' }));
147
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'appscript.run-function' });
148
+ expect(r.note).toMatch(/Apps Script editor/);
149
+ expect(callsTo('appscript', 'run')).toHaveLength(0);
150
+ });
151
+
152
+ it('token fallback: binds the arguments — different params are DRAFT_CHANGED', async () => {
153
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
154
+ stubReads({ 'appscript get': PROJECT });
155
+ const harness = await unprompted(registerAppScriptTools);
156
+ const args = { scriptId: 'S1', functionName: 'doWork', params: '[1]', devMode: true };
157
+ const p1 = json(await harness.callTool('gog_appscript_run_function', args));
158
+ expect(p1.preview).toEqual({ project: { scriptId: 'S1', title: 'Payroll sync' }, functionName: 'doWork', params: [1], devMode: true, runsAs: 'me@example.com' });
159
+ expect(json(await harness.callTool('gog_appscript_run_function', { ...args, params: '[2]', confirmToken: p1.confirmToken })))
160
+ .toMatchObject({ error: 'DRAFT_CHANGED' });
161
+ await harness.callTool('gog_appscript_run_function', { ...args, confirmToken: p1.confirmToken });
162
+ expect(callsTo('appscript', 'run')).toHaveLength(1);
163
+ expect(callsTo('appscript', 'run')[0]![0]).toContain('--dev-mode');
164
+ });
165
+
166
+ it('token fallback: code saved between the phases (updateTime rotated) is DRAFT_CHANGED', async () => {
167
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
168
+ const at = (updateTime: string) => JSON.stringify({ project: { scriptId: 'S1', title: 'Payroll sync', updateTime } });
169
+ stubReads({ 'appscript get': at('2026-09-24T10:00:00Z') });
170
+ const harness = await unprompted(registerAppScriptTools);
171
+ const args = { scriptId: 'S1', functionName: 'doWork' };
172
+ const p1 = json(await harness.callTool('gog_appscript_run_function', args));
173
+ expect(p1.preview.project).toEqual({ scriptId: 'S1', title: 'Payroll sync' });
174
+ stubReads({ 'appscript get': at('2026-09-24T10:05:00Z') });
175
+ expect(json(await harness.callTool('gog_appscript_run_function', { ...args, confirmToken: p1.confirmToken })))
176
+ .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'revision-changed' });
177
+ expect(callsTo('appscript', 'run')).toHaveLength(0);
178
+ });
179
+
180
+ it('projectSnapshot names nothing for unreadable output', () => {
181
+ expect(projectSnapshot('nope', 'S9')).toEqual({ scriptId: 'S9' });
182
+ expect(projectSnapshot('{"project":{"title":7}}', 'S9')).toEqual({ scriptId: 'S9' });
183
+ });
184
+ });
185
+
186
+ describe('gog_classroom_submissions_return', () => {
187
+ const COURSE = JSON.stringify({ course: { id: 'c1', name: 'Algebra II', section: 'Period 3' } });
188
+ const WORK = JSON.stringify({ coursework: { id: 'w1', title: 'HW 3', state: 'PUBLISHED' } });
189
+ const SUBMISSION = (updateTime = '2026-09-24T10:00:00Z') => JSON.stringify({
190
+ submission: { id: 's1', userId: '1234', state: 'TURNED_IN', draftGrade: 95, assignedGrade: 90, updateTime },
191
+ });
192
+ const STUDENT = JSON.stringify({ student: { userId: '1234', profile: { name: { fullName: 'Pat Example' }, emailAddress: 'pat@example.com' } } });
193
+ const READS = {
194
+ 'classroom courses get': COURSE,
195
+ 'classroom coursework get': WORK,
196
+ 'classroom submissions get': SUBMISSION(),
197
+ 'classroom students get': STUDENT,
198
+ };
199
+ const ARGS = { courseId: 'c1', courseworkId: 'w1', submissionId: 's1' };
200
+
201
+ it('reads the course, the coursework, the submission and the student, and asks showing them by name', async () => {
202
+ stubReads(READS);
203
+ const { harness, details } = await prompted(registerClassroomTools);
204
+ await harness.callTool('gog_classroom_submissions_return', ARGS);
205
+ expect(details()).toEqual({
206
+ course: { id: 'c1', name: 'Algebra II', section: 'Period 3' },
207
+ coursework: { id: 'w1', title: 'HW 3' },
208
+ submission: { id: 's1', student: 'Pat Example <pat@example.com>', state: 'TURNED_IN', draftGrade: 95, assignedGrade: 90 },
209
+ });
210
+ expect(callsTo('classroom', 'submissions', 'return')[0]![0]).toEqual(
211
+ ['classroom', 'submissions', 'return', pos('c1'), pos('w1'), pos('s1')]);
212
+ });
213
+
214
+ it('returns nothing when the user declines', async () => {
215
+ stubReads(READS);
216
+ const { harness } = await prompted(registerClassroomTools, { action: 'decline' });
217
+ const r = json(await harness.callTool('gog_classroom_submissions_return', ARGS));
218
+ expect(r).toMatchObject({ confirmed: false, cancelled: true, action: 'classroom.submission-return' });
219
+ expect(callsTo('classroom', 'submissions', 'return')).toHaveLength(0);
220
+ });
221
+
222
+ it.each(['classroom courses get', 'classroom coursework get', 'classroom submissions get'])(
223
+ 'returns a failed read (%s) without asking or returning', async (failing) => {
224
+ stubReads(READS, failing);
225
+ const { harness, seen } = await prompted(registerClassroomTools);
226
+ expect((await harness.callTool('gog_classroom_submissions_return', ARGS)).isError).toBe(true);
227
+ expect(seen).toHaveLength(0);
228
+ expect(callsTo('classroom', 'submissions', 'return')).toHaveLength(0);
229
+ });
230
+
231
+ it('refuses a client that cannot be prompted', async () => {
232
+ process.env.MCP_CONFIRM_MODE = 'refuse';
233
+ stubReads(READS);
234
+ const harness = await unprompted(registerClassroomTools);
235
+ const r = json(await harness.callTool('gog_classroom_submissions_return', ARGS));
236
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'classroom.submission-return' });
237
+ expect(r.note).toMatch(/return it from Classroom/);
238
+ });
239
+
240
+ it('token fallback: a submission re-graded between the phases (updateTime rotated) is DRAFT_CHANGED', async () => {
241
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
242
+ stubReads(READS);
243
+ const harness = await unprompted(registerClassroomTools);
244
+ const p1 = json(await harness.callTool('gog_classroom_submissions_return', ARGS));
245
+ expect(p1.preview).toMatchObject({ coursework: { title: 'HW 3' }, submission: { student: 'Pat Example <pat@example.com>', assignedGrade: 90 } });
246
+ stubReads({ ...READS, 'classroom submissions get': SUBMISSION('2026-09-24T10:05:00Z') });
247
+ expect(json(await harness.callTool('gog_classroom_submissions_return', { ...ARGS, confirmToken: p1.confirmToken })))
248
+ .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'revision-changed' });
249
+ stubReads(READS);
250
+ await harness.callTool('gog_classroom_submissions_return', { ...ARGS, confirmToken: p1.confirmToken });
251
+ expect(callsTo('classroom', 'submissions', 'return')).toHaveLength(1);
252
+ });
253
+
254
+ it('asks without a student line when the submission names no user', async () => {
255
+ stubReads({ ...READS, 'classroom submissions get': JSON.stringify({ submission: { id: 's1', state: 'TURNED_IN' } }) });
256
+ const { harness, details } = await prompted(registerClassroomTools, { action: 'decline' });
257
+ await harness.callTool('gog_classroom_submissions_return', ARGS);
258
+ expect(details().submission).toEqual({ id: 's1', state: 'TURNED_IN' });
259
+ expect(callsTo('classroom', 'students', 'get')).toHaveLength(0);
260
+ });
261
+
262
+ it('studentLabel falls back from name+email to either, then to the id', async () => {
263
+ const label = async (student: unknown) => {
264
+ vi.mocked(runner.run).mockResolvedValue(JSON.stringify({ student }));
265
+ return studentLabel('c1', 'u1', undefined);
266
+ };
267
+ expect(await label({ profile: { name: { fullName: 'Pat' } } })).toBe('Pat');
268
+ expect(await label({ profile: { emailAddress: 'p@example.com' } })).toBe('p@example.com');
269
+ expect(await label({})).toBe('u1');
270
+ vi.mocked(runner.run).mockResolvedValue('not json');
271
+ expect(await studentLabel('c1', 'u1', undefined)).toBe('u1');
272
+ vi.mocked(runner.run).mockRejectedValue(new Error('not found'));
273
+ expect(await studentLabel('c1', 'u1', undefined)).toBe('u1');
274
+ });
275
+
276
+ it('readCoursework and readSubmission name nothing for unreadable output', async () => {
277
+ vi.mocked(runner.run).mockResolvedValue('not json');
278
+ expect(await readCoursework('c9', 'w9', undefined)).toEqual({ coursework: { id: 'w9' } });
279
+ expect(await readSubmission('c9', 'w9', 's9', undefined)).toEqual({ submission: { id: 's9' } });
280
+ vi.mocked(runner.run).mockResolvedValue('{"coursework":{"title":7},"submission":{"userId":7,"state":null,"assignedGrade":"A"}}');
281
+ expect(await readCoursework('c9', 'w9', undefined)).toEqual({ coursework: { id: 'w9' } });
282
+ expect(await readSubmission('c9', 'w9', 's9', undefined)).toEqual({ submission: { id: 's9' } });
283
+ });
284
+ });
285
+
286
+ describe('gog_calendar_delete', () => {
287
+ const EVENT = (overrides: Record<string, unknown> = {}) => JSON.stringify({
288
+ event: {
289
+ id: 'e1',
290
+ etag: '"v1"',
291
+ summary: 'Budget review',
292
+ start: { dateTime: '2026-09-30T14:00:00-04:00' },
293
+ end: { dateTime: '2026-09-30T15:00:00-04:00' },
294
+ organizer: { email: 'me@example.com' },
295
+ attendees: [{ email: 'me@example.com', self: true }, { email: 'boss@example.com' }],
296
+ ...overrides,
297
+ },
298
+ });
299
+ const ARGS = { calendarId: 'primary', eventId: 'e1' };
300
+
301
+ it('deletes a guest-free, one-off event without asking', async () => {
302
+ stubReads({ 'calendar event': EVENT({ attendees: [{ email: 'me@example.com', self: true }] }) });
303
+ const { harness, seen } = await prompted(registerCalendarTools);
304
+ await harness.callTool('gog_calendar_delete', ARGS);
305
+ expect(seen).toHaveLength(0);
306
+ expect(callsTo('calendar', 'delete')[0]![0]).toEqual(['calendar', 'delete', pos('primary'), pos('e1'), '--force']);
307
+ });
308
+
309
+ it('asks before deleting an event other people are on, showing it as it stands', async () => {
310
+ stubReads({ 'calendar event': EVENT() });
311
+ const { harness, details } = await prompted(registerCalendarTools);
312
+ await harness.callTool('gog_calendar_delete', ARGS);
313
+ expect(details()).toEqual({
314
+ calendarId: 'primary',
315
+ eventId: 'e1',
316
+ event: {
317
+ summary: 'Budget review',
318
+ start: '2026-09-30T14:00:00-04:00',
319
+ end: '2026-09-30T15:00:00-04:00',
320
+ organizer: 'me@example.com',
321
+ guests: ['boss@example.com'],
322
+ },
323
+ scope: 'this event',
324
+ });
325
+ expect(callsTo('calendar', 'delete')).toHaveLength(1);
326
+ });
327
+
328
+ it.each([
329
+ ['a recurrence rule', { recurrence: ['RRULE:FREQ=WEEKLY'] }],
330
+ ['a recurringEventId', { recurringEventId: 'series1' }],
331
+ ])('asks before deleting a recurring series (%s), even guest-free, because --scope defaults to the whole series', async (_what, extra) => {
332
+ stubReads({ 'calendar event': EVENT({ attendees: undefined, ...extra }) });
333
+ const { harness, details } = await prompted(registerCalendarTools, { action: 'decline' });
334
+ await harness.callTool('gog_calendar_delete', ARGS);
335
+ expect(details()).toMatchObject({ event: { guests: [], recurring: true }, scope: 'the whole recurring series' });
336
+ expect(callsTo('calendar', 'delete')).toHaveLength(0);
337
+ });
338
+
339
+ it('returns a failed event read without deleting', async () => {
340
+ stubReads({}, 'calendar event');
341
+ const { harness } = await prompted(registerCalendarTools);
342
+ expect((await harness.callTool('gog_calendar_delete', ARGS)).isError).toBe(true);
343
+ expect(callsTo('calendar', 'delete')).toHaveLength(0);
344
+ });
345
+
346
+ it('refuses a client that cannot be prompted', async () => {
347
+ process.env.MCP_CONFIRM_MODE = 'refuse';
348
+ stubReads({ 'calendar event': EVENT() });
349
+ const harness = await unprompted(registerCalendarTools);
350
+ const r = json(await harness.callTool('gog_calendar_delete', ARGS));
351
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'calendar.delete' });
352
+ expect(r.note).toMatch(/delete it from Google Calendar/);
353
+ expect(callsTo('calendar', 'delete')).toHaveLength(0);
354
+ });
355
+
356
+ it('token fallback: an event edited elsewhere between the phases (etag rotated) is DRAFT_CHANGED', async () => {
357
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
358
+ stubReads({ 'calendar event': EVENT() });
359
+ const harness = await unprompted(registerCalendarTools);
360
+ const p1 = json(await harness.callTool('gog_calendar_delete', ARGS));
361
+ expect(p1.status).toBe('confirmation-required');
362
+ stubReads({ 'calendar event': EVENT({ etag: '"v2"' }) });
363
+ expect(json(await harness.callTool('gog_calendar_delete', { ...ARGS, confirmToken: p1.confirmToken })))
364
+ .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'revision-changed' });
365
+ stubReads({ 'calendar event': EVENT() });
366
+ await harness.callTool('gog_calendar_delete', { ...ARGS, confirmToken: p1.confirmToken });
367
+ expect(callsTo('calendar', 'delete')).toHaveLength(1);
368
+ });
369
+
370
+ it('eventSnapshot marks a series only when the event says so', () => {
371
+ expect(eventSnapshot(EVENT()).recurring).toBeUndefined();
372
+ expect(eventSnapshot(EVENT({ recurrence: [] })).recurring).toBeUndefined();
373
+ expect(eventSnapshot(EVENT({ recurringEventId: 'x' })).recurring).toBe(true);
374
+ });
375
+ });
376
+
377
+ describe('gog_drive_delete with permanent=true', () => {
378
+ const FILE = JSON.stringify({ file: { id: 'f1', name: 'Payroll 2026.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet' } });
379
+
380
+ it('trashes without reading or asking', async () => {
381
+ stubReads({ 'drive get': FILE });
382
+ const { harness, seen } = await prompted(registerDriveTools);
383
+ await harness.callTool('gog_drive_delete', { fileId: 'f1' });
384
+ expect(seen).toHaveLength(0);
385
+ expect(callsTo('drive', 'get')).toHaveLength(0);
386
+ expect(callsTo('drive', 'delete')[0]![0]).toEqual(['drive', 'delete', pos('f1'), '--force']);
387
+ });
388
+
389
+ it('reads the file and asks by name before a permanent delete', async () => {
390
+ stubReads({ 'drive get': FILE });
391
+ const { harness, details, seen } = await prompted(registerDriveTools);
392
+ await harness.callTool('gog_drive_delete', { fileId: 'f1', permanent: true });
393
+ expect(seen[0]!.params.message).toMatch(/PERMANENTLY/);
394
+ expect(details()).toEqual({
395
+ file: { id: 'f1', name: 'Payroll 2026.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet' },
396
+ permanent: true,
397
+ });
398
+ expect(callsTo('drive', 'delete')[0]![0]).toEqual(['drive', 'delete', pos('f1'), '--permanent', '--force']);
399
+ });
400
+
401
+ it('returns a failed read without deleting', async () => {
402
+ stubReads({}, 'drive get');
403
+ const { harness } = await prompted(registerDriveTools);
404
+ expect((await harness.callTool('gog_drive_delete', { fileId: 'f1', permanent: true })).isError).toBe(true);
405
+ expect(callsTo('drive', 'delete')).toHaveLength(0);
406
+ });
407
+
408
+ it('refuses a client that cannot be prompted, pointing at the trash', async () => {
409
+ process.env.MCP_CONFIRM_MODE = 'refuse';
410
+ stubReads({ 'drive get': FILE });
411
+ const harness = await unprompted(registerDriveTools);
412
+ const r = json(await harness.callTool('gog_drive_delete', { fileId: 'f1', permanent: true }));
413
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'drive.delete-permanent' });
414
+ expect(r.note).toMatch(/trash/);
415
+ expect(callsTo('drive', 'delete')).toHaveLength(0);
416
+ });
417
+
418
+ it('token fallback: binds the file as read — a renamed file is DRAFT_CHANGED', async () => {
419
+ process.env.MCP_CONFIRM_MODE = 'ask-user';
420
+ stubReads({ 'drive get': FILE });
421
+ const harness = await unprompted(registerDriveTools);
422
+ const args = { fileId: 'f1', permanent: true };
423
+ const p1 = json(await harness.callTool('gog_drive_delete', args));
424
+ expect(p1.preview).toEqual({ file: { id: 'f1', name: 'Payroll 2026.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet' }, permanent: true });
425
+ stubReads({ 'drive get': JSON.stringify({ file: { id: 'f1', name: 'Renamed' } }) });
426
+ expect(json(await harness.callTool('gog_drive_delete', { ...args, confirmToken: p1.confirmToken }))).toMatchObject({ error: 'DRAFT_CHANGED' });
427
+ stubReads({ 'drive get': FILE });
428
+ await harness.callTool('gog_drive_delete', { ...args, confirmToken: p1.confirmToken });
429
+ expect(callsTo('drive', 'delete')).toHaveLength(1);
430
+ });
431
+
432
+ it('commentSnapshot reads a comment and tolerates unreadable output', () => {
433
+ expect(commentSnapshot(JSON.stringify({ comment: { id: 'c1', content: 'Please fix', author: { displayName: 'Alice', emailAddress: 'alice@example.com' }, resolved: false } })))
434
+ .toEqual({ author: 'Alice <alice@example.com>', content: 'Please fix', resolved: false });
435
+ expect(commentSnapshot(JSON.stringify({ comment: { content: 'x', author: { displayName: 'Bob' } } }))).toEqual({ author: 'Bob', content: 'x' });
436
+ expect(commentSnapshot(JSON.stringify({ comment: { author: { emailAddress: 'b@example.com' }, resolved: 'yes' } }))).toEqual({ author: 'b@example.com' });
437
+ expect(commentSnapshot(JSON.stringify({ content: 'bare', author: { displayName: 'Cy' } }))).toEqual({ author: 'Cy', content: 'bare' });
438
+ expect(commentSnapshot('null')).toEqual({});
439
+ expect(commentSnapshot('nope')).toEqual({});
440
+ });
441
+
442
+ it('commentMentions finds +/@ mentions once each, ignoring bare addresses', () => {
443
+ expect(commentMentions('+alice@example.com please check with @bob@example.co.uk and +alice@example.com'))
444
+ .toEqual(['alice@example.com', 'bob@example.co.uk']);
445
+ expect(commentMentions('mail carol@example.com about it')).toEqual([]);
446
+ });
447
+ });
@@ -7,7 +7,7 @@ import { resetConfirmTokenState } from '../../src/send-confirm-token.js';
7
7
  import { CONFIRM_ACTION_INSTRUCTION } from '../../src/dispatch-confirmation.js';
8
8
  import { registerChatTools } from '../../src/tools/chat.js';
9
9
  import { registerDriveTools, shareTargetMeta } from '../../src/tools/drive.js';
10
- import { registerClassroomTools, readCourse } from '../../src/tools/classroom.js';
10
+ import { registerClassroomTools, readCourse, readClassroomWork } from '../../src/tools/classroom.js';
11
11
  import { registerCalendarTools, eventSnapshot, parseAttendees } from '../../src/tools/calendar.js';
12
12
 
13
13
  vi.mock('../../src/runner.js');
@@ -192,6 +192,16 @@ describe('gog_classroom_announcements_create', () => {
192
192
  expect(callsTo('classroom', 'announcements', 'create')).toHaveLength(1);
193
193
  });
194
194
 
195
+ // A scheduled item is a DRAFT that publishes itself at that time, so a
196
+ // DRAFT with a schedule reaches the class too and asks like any other.
197
+ it('asks for a scheduled DRAFT, which publishes itself', async () => {
198
+ stubReads({ 'classroom courses get': COURSE });
199
+ const { harness, details } = await prompted(registerClassroomTools, { action: 'decline' });
200
+ await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'Quiz Friday', state: 'DRAFT', scheduled: '2026-09-25T08:00:00Z' });
201
+ expect(details()).toMatchObject({ publishes: 'at 2026-09-25T08:00:00Z', textPreview: 'Quiz Friday' });
202
+ expect(callsTo('classroom', 'announcements', 'create')).toHaveLength(0);
203
+ });
204
+
195
205
  it('refuses a client that cannot be prompted, pointing at DRAFT', async () => {
196
206
 
197
207
  process.env.MCP_CONFIRM_MODE = 'refuse';
@@ -225,6 +235,47 @@ describe('gog_classroom_announcements_create', () => {
225
235
  vi.mocked(runner.run).mockResolvedValue('{"course":{"name":7}}');
226
236
  expect(await readCourse('c9', undefined)).toEqual({ course: { id: 'c9' } });
227
237
  });
238
+
239
+ // The item a publish-through-update reaches students with (SEC-3, fleet-audit
240
+ // #932): its text or title for the prompt, its updateTime as the token's
241
+ // revision. gog nests the payload under the API resource name.
242
+ describe('readClassroomWork', () => {
243
+ it('reads an announcement: text, state and updateTime', async () => {
244
+ // runOrDiagnose renders instants in DISPLAY_TZ (America/New_York by default).
245
+ vi.mocked(runner.run).mockResolvedValue(JSON.stringify({ announcement: { id: 'a1', text: 'Quiz Friday', state: 'DRAFT', updateTime: '2026-09-20T10:00:00Z', creatorUserId: '7' } }));
246
+ expect(await readClassroomWork('announcements', 'c1', 'a1', 'me@example.com')).toEqual({
247
+ work: { id: 'a1', text: 'Quiz Friday', state: 'DRAFT', updateTime: '2026-09-20T06:00:00-04:00' },
248
+ });
249
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'announcements', 'get', pos('c1'), pos('a1')], { account: 'me@example.com' });
250
+ });
251
+
252
+ // gog 0.41.0 writes `{"coursework": …}` (internal/cmd/classroom_coursework.go),
253
+ // not the API's `courseWork`: reading the wrong key named nothing and left
254
+ // the token fallback without a revision to bind.
255
+ it('reads coursework under gog\'s key: title, description, state and updateTime', async () => {
256
+ vi.mocked(runner.run).mockResolvedValue(JSON.stringify({ coursework: { id: 'w1', title: 'Problem set', description: 'Work problems 1-20', state: 'PUBLISHED', updateTime: '2026-09-21T10:00:00Z' } }));
257
+ expect(await readClassroomWork('coursework', 'c1', 'w1', undefined)).toEqual({
258
+ work: { id: 'w1', title: 'Problem set', description: 'Work problems 1-20', state: 'PUBLISHED', updateTime: '2026-09-21T06:00:00-04:00' },
259
+ });
260
+ expect(runner.run).toHaveBeenCalledWith(['classroom', 'coursework', 'get', pos('c1'), pos('w1')], { account: undefined });
261
+ });
262
+
263
+ it('names nothing for unreadable output or fields of the wrong type', async () => {
264
+ vi.mocked(runner.run).mockResolvedValue('not json');
265
+ expect(await readClassroomWork('announcements', 'c1', 'a1', undefined)).toEqual({ work: { id: 'a1' } });
266
+ vi.mocked(runner.run).mockResolvedValue('{"announcement":{"text":7,"state":null,"updateTime":[]}}');
267
+ expect(await readClassroomWork('announcements', 'c1', 'a1', undefined)).toEqual({ work: { id: 'a1' } });
268
+ vi.mocked(runner.run).mockResolvedValue('null');
269
+ expect(await readClassroomWork('coursework', 'c1', 'w1', undefined)).toEqual({ work: { id: 'w1' } });
270
+ });
271
+
272
+ it('passes a failed read back unchanged', async () => {
273
+ vi.mocked(runner.run).mockRejectedValue(new Error('not found'));
274
+ const got = await readClassroomWork('coursework', 'c1', 'w1', undefined);
275
+ expect(got.error?.isError).toBe(true);
276
+ expect('work' in got).toBe(false);
277
+ });
278
+ });
228
279
  });
229
280
 
230
281
  describe('calendar', () => {
@@ -19,13 +19,13 @@ describe('gog_docs_info', () => {
19
19
  expect(result.content[0].text).toContain('My Doc');
20
20
  });
21
21
 
22
- it('appends auth list on failure when auth list succeeds', async () => {
22
+ it('appends the account list only on an auth failure', async () => {
23
23
  vi.mocked(runner.run)
24
- .mockRejectedValueOnce(new Error('Doc not found'))
25
- .mockResolvedValueOnce('user@gmail.com');
24
+ .mockRejectedValueOnce(new Error('Doc not found'));
26
25
  const harness = await setupHandlers();
27
26
  const result = await harness.callTool('gog_docs_info', { docId: 'bad' });
28
- expect(result.content[0].text).toBe('Error: Doc not found\n\nConfigured accounts:\nuser@gmail.com');
27
+ expect(result.content[0].text).toBe('Error: Doc not found');
28
+ expect(runner.run).toHaveBeenCalledTimes(1);
29
29
  });
30
30
 
31
31
  it('returns plain error text when auth list also fails', async () => {
@@ -37,8 +37,7 @@ describe('gog_docs_info', () => {
37
37
 
38
38
  it('handles non-Error rejection', async () => {
39
39
  vi.mocked(runner.run)
40
- .mockRejectedValueOnce('raw error string')
41
- .mockRejectedValueOnce(new Error('auth list failed'));
40
+ .mockRejectedValueOnce('raw error string');
42
41
  const harness = await setupHandlers();
43
42
  const result = await harness.callTool('gog_docs_info', { docId: 'bad' });
44
43
  expect(result.content[0].text).toBe('raw error string');
@@ -433,7 +433,6 @@ describe('gog_drive_extract_text', () => {
433
433
  .mockResolvedValueOnce(PDF_META) // drive get
434
434
  .mockResolvedValueOnce(JSON.stringify({ id: 'tmpDoc3' })) // files.copy
435
435
  .mockRejectedValueOnce(new Error('export exploded')) // files.export fails
436
- .mockResolvedValueOnce('user@x.com') // diagnose -> auth list
437
436
  .mockResolvedValueOnce('deleted'); // cleanup delete
438
437
  const harness = await setupHandlers();
439
438
  const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
@@ -445,8 +444,7 @@ describe('gog_drive_extract_text', () => {
445
444
  it('errors clearly when OCR conversion returns no id (and creates nothing to clean up)', async () => {
446
445
  vi.mocked(runner.run)
447
446
  .mockResolvedValueOnce(PDF_META) // drive get
448
- .mockResolvedValueOnce('{}') // files.copy: no id
449
- .mockResolvedValueOnce('user@x.com'); // diagnose -> auth list
447
+ .mockResolvedValueOnce('{}'); // files.copy: no id
450
448
  const harness = await setupHandlers();
451
449
  const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
452
450
  expect(result.isError).toBe(true);
@@ -560,8 +558,7 @@ describe('gog_drive_read_bytes', () => {
560
558
 
561
559
  it('surfaces a byte-fetch failure via diagnose', async () => {
562
560
  vi.mocked(runner.run)
563
- .mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.pdf', mimeType: 'application/pdf' } }))
564
- .mockResolvedValueOnce('user@x.com'); // diagnose -> auth list
561
+ .mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.pdf', mimeType: 'application/pdf' } }));
565
562
  vi.mocked(runner.runBinary).mockRejectedValueOnce(new Error('gog exited with code 1'));
566
563
  const harness = await setupHandlers();
567
564
  const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });