gogcli-mcp-classroom 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/dist/index.js +571 -135
- package/manifest.json +9 -9
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/tools/classroom-extra.ts +247 -19
- package/tests/tools/classroom-extra-gates.test.ts +211 -0
- package/tests/tools/classroom-extra.test.ts +233 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { registerExtraClassroomTools } from '../../src/tools/classroom-extra.js';
|
|
3
|
+
import * as lib from '../../../gogcli-mcp/src/lib.js';
|
|
4
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
|
+
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
6
|
+
import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
|
|
7
|
+
import { pos } from '../../../gogcli-mcp/src/argv.js';
|
|
8
|
+
import { resetConfirmTokenState } from '../../../gogcli-mcp/src/send-confirm-token.js';
|
|
9
|
+
|
|
10
|
+
vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
11
|
+
const actual = await importOriginal<typeof lib>();
|
|
12
|
+
return { ...actual, runOrDiagnose: vi.fn() };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Fleet audit 2026-09-24 SEC-6: roster adds grant access to a class, coursework
|
|
17
|
+
// is announced to it, and the deletes destroy submissions for good — each asks
|
|
18
|
+
// first, naming the class (and coursework) rather than an opaque id.
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
process.env = { ...ORIGINAL_ENV };
|
|
25
|
+
delete process.env.MCP_CONFIRM_MODE;
|
|
26
|
+
resetConfirmTokenState();
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => { process.env = ORIGINAL_ENV; });
|
|
29
|
+
|
|
30
|
+
const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
|
|
31
|
+
async function prompted(answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
|
|
32
|
+
const seen: ElicitRequest[] = [];
|
|
33
|
+
const harness = await createTestHarness(registerExtraClassroomTools, { elicitation: async (r) => { seen.push(r); return answer; } });
|
|
34
|
+
const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
|
|
35
|
+
return { harness, seen, details };
|
|
36
|
+
}
|
|
37
|
+
const unprompted = () => createTestHarness(registerExtraClassroomTools);
|
|
38
|
+
|
|
39
|
+
const COURSE = JSON.stringify({ course: { id: 'c1', name: 'Algebra II', section: 'Period 3' } });
|
|
40
|
+
const WORK = JSON.stringify({ coursework: { id: 'w1', title: 'HW 3' } });
|
|
41
|
+
const COURSE_VIEW = { id: 'c1', name: 'Algebra II', section: 'Period 3' };
|
|
42
|
+
|
|
43
|
+
function stub(failing?: string) {
|
|
44
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args) => {
|
|
45
|
+
const key = args.filter((a): a is string => typeof a === 'string' && !a.startsWith('--')).join(' ');
|
|
46
|
+
if (failing && key.startsWith(failing)) return { content: [{ type: 'text', text: 'Error: not found' }], isError: true };
|
|
47
|
+
if (key.startsWith('classroom courses get')) return rawTextResult(COURSE);
|
|
48
|
+
if (key.startsWith('classroom coursework get')) return rawTextResult(WORK);
|
|
49
|
+
return rawTextResult('{"ok":true}');
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const callsTo = (...words: string[]) => vi.mocked(lib.runOrDiagnose).mock.calls.filter(([args]) =>
|
|
53
|
+
words.every((w, i) => args[i] === w));
|
|
54
|
+
|
|
55
|
+
type Case = {
|
|
56
|
+
tool: string;
|
|
57
|
+
args: Record<string, unknown>;
|
|
58
|
+
action: string;
|
|
59
|
+
details: Record<string, unknown>;
|
|
60
|
+
write: string[];
|
|
61
|
+
argv: unknown[];
|
|
62
|
+
note: RegExp;
|
|
63
|
+
reads: string[];
|
|
64
|
+
changed: Record<string, unknown>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const CASES: Case[] = [
|
|
68
|
+
{
|
|
69
|
+
tool: 'gog_classroom_students_add',
|
|
70
|
+
args: { courseId: 'c1', userId: 'kid@example.com' },
|
|
71
|
+
action: 'classroom.student-add',
|
|
72
|
+
details: { course: COURSE_VIEW, student: 'kid@example.com' },
|
|
73
|
+
write: ['classroom', 'students', 'add'],
|
|
74
|
+
argv: ['classroom', 'students', 'add', pos('c1'), pos('kid@example.com')],
|
|
75
|
+
note: /gog_classroom_invitations_create/,
|
|
76
|
+
reads: ['classroom courses get'],
|
|
77
|
+
changed: { userId: 'other@example.com' },
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
tool: 'gog_classroom_teachers_add',
|
|
81
|
+
args: { courseId: 'c1', userId: 'co@example.com' },
|
|
82
|
+
action: 'classroom.teacher-add',
|
|
83
|
+
details: { course: COURSE_VIEW, teacher: 'co@example.com', grants: "teacher access: every student's work, grades and roster" },
|
|
84
|
+
write: ['classroom', 'teachers', 'add'],
|
|
85
|
+
argv: ['classroom', 'teachers', 'add', pos('c1'), pos('co@example.com')],
|
|
86
|
+
note: /invitations_create/,
|
|
87
|
+
reads: ['classroom courses get'],
|
|
88
|
+
changed: { userId: 'other@example.com' },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
tool: 'gog_classroom_courses_delete',
|
|
92
|
+
args: { courseId: 'c1' },
|
|
93
|
+
action: 'classroom.course-delete',
|
|
94
|
+
details: { course: COURSE_VIEW, deletes: 'the course with all of its coursework, submissions and grades, for good' },
|
|
95
|
+
write: ['classroom', 'courses', 'delete'],
|
|
96
|
+
argv: ['classroom', 'courses', 'delete', pos('c1'), '--force'],
|
|
97
|
+
note: /gog_classroom_courses_archive/,
|
|
98
|
+
reads: ['classroom courses get'],
|
|
99
|
+
changed: { courseId: 'c2' },
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
tool: 'gog_classroom_coursework_delete',
|
|
103
|
+
args: { courseId: 'c1', courseworkId: 'w1' },
|
|
104
|
+
action: 'classroom.coursework-delete',
|
|
105
|
+
details: { course: COURSE_VIEW, coursework: { id: 'w1', title: 'HW 3' }, deletes: 'the coursework and every student submission to it, for good' },
|
|
106
|
+
write: ['classroom', 'coursework', 'delete'],
|
|
107
|
+
argv: ['classroom', 'coursework', 'delete', pos('c1'), pos('w1'), '--force'],
|
|
108
|
+
note: /delete it from Classroom/,
|
|
109
|
+
reads: ['classroom courses get', 'classroom coursework get'],
|
|
110
|
+
changed: { courseworkId: 'w2' },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
tool: 'gog_classroom_coursework_create',
|
|
114
|
+
args: { courseId: 'c1', title: 'HW 4', description: 'Chapter 5, all odd problems', dueDate: '2026-10-01', dueTime: '09:00', maxPoints: 10 },
|
|
115
|
+
action: 'classroom.coursework-create',
|
|
116
|
+
details: {
|
|
117
|
+
course: COURSE_VIEW, title: 'HW 4', type: 'ASSIGNMENT', publishes: 'immediately', due: '2026-10-01 09:00',
|
|
118
|
+
maxPoints: 10, descriptionPreview: 'Chapter 5, all odd problems',
|
|
119
|
+
},
|
|
120
|
+
write: ['classroom', 'coursework', 'create'],
|
|
121
|
+
argv: ['classroom', 'coursework', 'create', pos('c1'), '--title=HW 4', '--description=Chapter 5, all odd problems',
|
|
122
|
+
'--max-points=10', '--due-date=2026-10-01', '--due-time=09:00'],
|
|
123
|
+
note: /state DRAFT/,
|
|
124
|
+
reads: ['classroom courses get'],
|
|
125
|
+
changed: { description: 'Chapter 6' },
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
describe.each(CASES)('$tool', (c) => {
|
|
130
|
+
it('reads what it acts on and asks, naming it; acts once accepted', async () => {
|
|
131
|
+
stub();
|
|
132
|
+
const { harness, details } = await prompted();
|
|
133
|
+
await harness.callTool(c.tool, c.args);
|
|
134
|
+
expect(details()).toEqual(c.details);
|
|
135
|
+
expect(callsTo(...c.write).map(([args]) => args)).toEqual([c.argv]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does nothing when the user declines', async () => {
|
|
139
|
+
stub();
|
|
140
|
+
const { harness } = await prompted({ action: 'decline' });
|
|
141
|
+
expect(json(await harness.callTool(c.tool, c.args))).toMatchObject({ cancelled: true, action: c.action });
|
|
142
|
+
expect(callsTo(...c.write)).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it.each(c.reads)('returns a failed read (%s) without asking or acting', async (failing) => {
|
|
146
|
+
stub(failing);
|
|
147
|
+
const { harness, seen } = await prompted();
|
|
148
|
+
expect((await harness.callTool(c.tool, c.args)).isError).toBe(true);
|
|
149
|
+
expect(seen).toHaveLength(0);
|
|
150
|
+
expect(callsTo(...c.write)).toHaveLength(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('refuses a client that cannot be prompted, with a way through', async () => {
|
|
154
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
155
|
+
stub();
|
|
156
|
+
const r = json(await (await unprompted()).callTool(c.tool, c.args));
|
|
157
|
+
expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: c.action });
|
|
158
|
+
expect(r.note).toMatch(c.note);
|
|
159
|
+
expect(callsTo(...c.write)).toHaveLength(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('token fallback: previews in full, refuses changed arguments, acts on phase 2', async () => {
|
|
163
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
164
|
+
stub();
|
|
165
|
+
const harness = await unprompted();
|
|
166
|
+
const p1 = json(await harness.callTool(c.tool, c.args));
|
|
167
|
+
expect(p1.status).toBe('confirmation-required');
|
|
168
|
+
expect(p1.preview.course).toEqual(COURSE_VIEW);
|
|
169
|
+
expect(json(await harness.callTool(c.tool, { ...c.args, ...c.changed, confirmToken: p1.confirmToken })))
|
|
170
|
+
.toMatchObject({ error: expect.stringMatching(/DRAFT_CHANGED|TOKEN_INVALID/) });
|
|
171
|
+
await harness.callTool(c.tool, { ...c.args, confirmToken: p1.confirmToken });
|
|
172
|
+
expect(callsTo(...c.write)).toHaveLength(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('the paths that reach nobody stay unprompted', () => {
|
|
177
|
+
it('enrolling yourself with a code', async () => {
|
|
178
|
+
stub();
|
|
179
|
+
const { harness, seen } = await prompted();
|
|
180
|
+
await harness.callTool('gog_classroom_students_add', { courseId: 'c1', userId: 'ME', enrollmentCode: 'abc123' });
|
|
181
|
+
expect(seen).toHaveLength(0);
|
|
182
|
+
expect(callsTo('classroom', 'courses', 'get')).toHaveLength(0);
|
|
183
|
+
expect(callsTo('classroom', 'students', 'add')[0]![0]).toEqual(
|
|
184
|
+
['classroom', 'students', 'add', pos('c1'), pos('ME'), '--enrollment-code=abc123']);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('a DRAFT coursework item', async () => {
|
|
188
|
+
stub();
|
|
189
|
+
const { harness, seen } = await prompted();
|
|
190
|
+
await harness.callTool('gog_classroom_coursework_create', { courseId: 'c1', title: 'HW 4', state: 'DRAFT' });
|
|
191
|
+
expect(seen).toHaveLength(0);
|
|
192
|
+
expect(callsTo('classroom', 'coursework', 'create')).toHaveLength(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// A scheduled item is a DRAFT that publishes itself at that time: only an
|
|
196
|
+
// unscheduled DRAFT reaches nobody.
|
|
197
|
+
it('a scheduled DRAFT coursework item still asks', async () => {
|
|
198
|
+
stub();
|
|
199
|
+
const { harness, details } = await prompted({ action: 'decline' });
|
|
200
|
+
await harness.callTool('gog_classroom_coursework_create', { courseId: 'c1', title: 'Quiz', state: 'DRAFT', scheduled: '2026-10-01T08:00' });
|
|
201
|
+
expect(details()).toMatchObject({ course: COURSE_VIEW, title: 'Quiz', publishes: 'at 2026-10-01T08:00' });
|
|
202
|
+
expect(callsTo('classroom', 'coursework', 'create')).toHaveLength(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('a scheduled coursework item says when it publishes, and a combined due passes through', async () => {
|
|
206
|
+
stub();
|
|
207
|
+
const { harness, details } = await prompted({ action: 'decline' });
|
|
208
|
+
await harness.callTool('gog_classroom_coursework_create', { courseId: 'c1', title: 'Quiz', type: 'SHORT_ANSWER_QUESTION', due: '2026-10-02T09:00', scheduled: '2026-10-01T08:00' });
|
|
209
|
+
expect(details()).toEqual({ course: COURSE_VIEW, title: 'Quiz', type: 'SHORT_ANSWER_QUESTION', publishes: 'at 2026-10-01T08:00', due: '2026-10-02T09:00' });
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -219,6 +219,239 @@ describe('gog_classroom_announcements_update', () => {
|
|
|
219
219
|
});
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// SEC-3 (fleet-audit #932): create-as-DRAFT then update-to-PUBLISHED was the
|
|
224
|
+
// same two-step around the create gate that closed for Gmail drafts. Publishing
|
|
225
|
+
// (state PUBLISHED, or a schedule) through update now reads the course and the
|
|
226
|
+
// item and asks, exactly as announcements_create does.
|
|
227
|
+
// ============================================================================
|
|
228
|
+
|
|
229
|
+
const COURSE_JSON = JSON.stringify({ course: { id: 'c1', name: 'Algebra II', section: 'Period 3' } });
|
|
230
|
+
const ANNOUNCEMENT_JSON = JSON.stringify({ announcement: { id: 'a1', text: 'Homework is due Friday', state: 'DRAFT', updateTime: '2026-09-20T10:00:00Z' } });
|
|
231
|
+
// gog 0.41.0 nests coursework under `coursework` (not the API's `courseWork`).
|
|
232
|
+
const COURSEWORK_JSON = JSON.stringify({ coursework: { id: 'w1', title: 'Chapter 4 problem set', description: 'Problems 1-20, show your work', state: 'DRAFT', updateTime: '2026-09-21T10:00:00Z' } });
|
|
233
|
+
|
|
234
|
+
/** Answer reads with `reads[<joined command words>]`, everything else with `{}`. */
|
|
235
|
+
function stubReads(reads: Record<string, string>) {
|
|
236
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args) => {
|
|
237
|
+
const key = (args as unknown[]).map((a) => (typeof a === 'string' ? a : (a as { value: string }).value)).filter((a) => !a.startsWith('--')).join(' ');
|
|
238
|
+
for (const [prefix, out] of Object.entries(reads)) if (key.startsWith(prefix)) return rawTextResult(out);
|
|
239
|
+
return rawTextResult('{"ok":true}');
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const calls = (...words: string[]) => vi.mocked(lib.runOrDiagnose).mock.calls.filter(([args]) =>
|
|
244
|
+
words.every((w, i) => args[i] === w));
|
|
245
|
+
const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
|
|
246
|
+
|
|
247
|
+
async function prompted(answer: { action: 'accept' | 'decline'; content?: { confirmed: boolean } } = { action: 'accept', content: { confirmed: true } }) {
|
|
248
|
+
const messages: string[] = [];
|
|
249
|
+
const h = await createTestHarness(registerExtraClassroomTools, {
|
|
250
|
+
elicitation: async (r) => { messages.push(r.params.message); return answer; },
|
|
251
|
+
});
|
|
252
|
+
const details = () => JSON.parse(messages[0]!.split('\n').slice(1).join('\n')).details;
|
|
253
|
+
return { h, messages, details };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
describe('gog_classroom_announcements_update — publishing asks', () => {
|
|
257
|
+
beforeEach(() => stubReads({ 'classroom courses get': COURSE_JSON, 'classroom announcements get': ANNOUNCEMENT_JSON }));
|
|
258
|
+
|
|
259
|
+
it('reads the course and the announcement, prompts with the class and the CURRENT text, then publishes once accepted', async () => {
|
|
260
|
+
const { h, details, messages } = await prompted();
|
|
261
|
+
await h.callTool('gog_classroom_announcements_update', { courseId: 'c1', announcementId: 'a1', state: 'PUBLISHED' });
|
|
262
|
+
expect(messages[0]).toMatch(/publish/i);
|
|
263
|
+
expect(details()).toEqual({
|
|
264
|
+
course: { id: 'c1', name: 'Algebra II', section: 'Period 3' },
|
|
265
|
+
announcement: { id: 'a1', state: 'DRAFT' },
|
|
266
|
+
publishes: 'immediately',
|
|
267
|
+
textPreview: 'Homework is due Friday',
|
|
268
|
+
});
|
|
269
|
+
// Read before asking (the elicitation round-trip re-runs the handler, so
|
|
270
|
+
// the reads repeat); the write happens exactly once, after acceptance.
|
|
271
|
+
expect(calls('classroom', 'courses', 'get').length).toBeGreaterThanOrEqual(1);
|
|
272
|
+
expect(calls('classroom', 'announcements', 'get').length).toBeGreaterThanOrEqual(1);
|
|
273
|
+
expect(calls('classroom', 'announcements', 'update')).toHaveLength(1);
|
|
274
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
275
|
+
['classroom', 'announcements', 'update', pos('c1'), pos('a1'), '--state=PUBLISHED'], { account: undefined });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('previews the NEW text when the same call replaces it, and a schedule as when it publishes', async () => {
|
|
279
|
+
const { h, details } = await prompted({ action: 'decline' });
|
|
280
|
+
await h.callTool('gog_classroom_announcements_update', {
|
|
281
|
+
courseId: 'c1', announcementId: 'a1', text: 'Homework is due Monday', state: 'DRAFT', scheduled: '2026-10-01T12:00:00Z', account: 't@example.com',
|
|
282
|
+
});
|
|
283
|
+
expect(details()).toMatchObject({ publishes: 'at 2026-10-01T12:00:00Z', textPreview: 'Homework is due Monday' });
|
|
284
|
+
expect(calls('classroom', 'announcements', 'update')).toHaveLength(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('previews the CURRENT text when text is empty (gog keeps it: an empty --text is never sent)', async () => {
|
|
288
|
+
const { h, details } = await prompted({ action: 'decline' });
|
|
289
|
+
await h.callTool('gog_classroom_announcements_update', { courseId: 'c1', announcementId: 'a1', text: '', state: 'PUBLISHED' });
|
|
290
|
+
expect(details()).toMatchObject({ textPreview: 'Homework is due Friday' });
|
|
291
|
+
expect(calls('classroom', 'announcements', 'update')).toHaveLength(0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it.each([
|
|
295
|
+
['a text edit', { text: 'fixed typo' }],
|
|
296
|
+
['unpublishing back to DRAFT', { state: 'DRAFT' as const }],
|
|
297
|
+
])('does not ask for %s (nobody new sees it)', async (_label, extra) => {
|
|
298
|
+
const { h, messages } = await prompted();
|
|
299
|
+
await h.callTool('gog_classroom_announcements_update', { courseId: 'c1', announcementId: 'a1', ...extra });
|
|
300
|
+
expect(messages).toHaveLength(0);
|
|
301
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
302
|
+
expect(calls('classroom', 'announcements', 'update')).toHaveLength(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('publishes nothing when the user declines', async () => {
|
|
306
|
+
const { h } = await prompted({ action: 'decline' });
|
|
307
|
+
const r = json(await h.callTool('gog_classroom_announcements_update', { courseId: 'c1', announcementId: 'a1', state: 'PUBLISHED' }));
|
|
308
|
+
expect(r).toMatchObject({ confirmed: false, cancelled: true, action: 'classroom.announcement-publish' });
|
|
309
|
+
expect(calls('classroom', 'announcements', 'update')).toHaveLength(0);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('refuses a client that cannot be prompted under MCP_CONFIRM_MODE=refuse, naming the way through', async () => {
|
|
313
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
314
|
+
const h = await createTestHarness(registerExtraClassroomTools);
|
|
315
|
+
const r = json(await h.callTool('gog_classroom_announcements_update', { courseId: 'c1', announcementId: 'a1', state: 'PUBLISHED' }));
|
|
316
|
+
expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'classroom.announcement-publish' });
|
|
317
|
+
expect(r.note).toMatch(/DRAFT/);
|
|
318
|
+
expect(calls('classroom', 'announcements', 'update')).toHaveLength(0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('token fallback: previews, binds the announcement revision, publishes on phase 2', async () => {
|
|
322
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
323
|
+
const h = await createTestHarness(registerExtraClassroomTools);
|
|
324
|
+
const args = { courseId: 'c1', announcementId: 'a1', state: 'PUBLISHED' as const };
|
|
325
|
+
const p1 = json(await h.callTool('gog_classroom_announcements_update', args));
|
|
326
|
+
expect(p1).toMatchObject({
|
|
327
|
+
status: 'confirmation-required',
|
|
328
|
+
preview: { course: { id: 'c1', name: 'Algebra II', section: 'Period 3' }, announcement: { id: 'a1', state: 'DRAFT' }, publishes: 'immediately', textPreview: 'Homework is due Friday' },
|
|
329
|
+
});
|
|
330
|
+
expect(calls('classroom', 'announcements', 'update')).toHaveLength(0);
|
|
331
|
+
// The teacher edits the draft between the phases: the approval no longer names this text.
|
|
332
|
+
stubReads({ 'classroom courses get': COURSE_JSON, 'classroom announcements get': ANNOUNCEMENT_JSON.replace('2026-09-20T10:00:00Z', '2026-09-22T08:00:00Z') });
|
|
333
|
+
expect(json(await h.callTool('gog_classroom_announcements_update', { ...args, confirmToken: p1.confirmToken }))).toMatchObject({ error: 'DRAFT_CHANGED' });
|
|
334
|
+
stubReads({ 'classroom courses get': COURSE_JSON, 'classroom announcements get': ANNOUNCEMENT_JSON });
|
|
335
|
+
await h.callTool('gog_classroom_announcements_update', { ...args, confirmToken: p1.confirmToken });
|
|
336
|
+
expect(calls('classroom', 'announcements', 'update')).toHaveLength(1);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it.each([
|
|
340
|
+
['course', 'classroom courses get'],
|
|
341
|
+
['announcement', 'classroom announcements get'],
|
|
342
|
+
])('returns a failed %s read without prompting or publishing', async (_what, failing) => {
|
|
343
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args) => {
|
|
344
|
+
const key = (args as unknown[]).map((a) => (typeof a === 'string' ? a : (a as { value: string }).value)).join(' ');
|
|
345
|
+
if (key.startsWith(failing)) return { content: [{ type: 'text', text: 'Error: not found' }], isError: true };
|
|
346
|
+
return rawTextResult(key.startsWith('classroom courses get') ? COURSE_JSON : ANNOUNCEMENT_JSON);
|
|
347
|
+
});
|
|
348
|
+
const { h, messages } = await prompted();
|
|
349
|
+
const r = await h.callTool('gog_classroom_announcements_update', { courseId: 'c1', announcementId: 'a1', state: 'PUBLISHED' });
|
|
350
|
+
expect(r.isError).toBe(true);
|
|
351
|
+
expect(messages).toHaveLength(0);
|
|
352
|
+
expect(calls('classroom', 'announcements', 'update')).toHaveLength(0);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('gog_classroom_coursework_update — publishing asks', () => {
|
|
357
|
+
beforeEach(() => stubReads({ 'classroom courses get': COURSE_JSON, 'classroom coursework get': COURSEWORK_JSON }));
|
|
358
|
+
|
|
359
|
+
it('reads the course and the coursework, prompts with the class and the CURRENT title and description, then publishes once accepted', async () => {
|
|
360
|
+
const { h, details, messages } = await prompted();
|
|
361
|
+
await h.callTool('gog_classroom_coursework_update', { courseId: 'c1', courseworkId: 'w1', state: 'PUBLISHED', maxPoints: 100 });
|
|
362
|
+
expect(messages[0]).toMatch(/publish/i);
|
|
363
|
+
expect(details()).toEqual({
|
|
364
|
+
course: { id: 'c1', name: 'Algebra II', section: 'Period 3' },
|
|
365
|
+
coursework: { id: 'w1', title: 'Chapter 4 problem set', state: 'DRAFT' },
|
|
366
|
+
publishes: 'immediately',
|
|
367
|
+
descriptionPreview: 'Problems 1-20, show your work',
|
|
368
|
+
});
|
|
369
|
+
expect(calls('classroom', 'courses', 'get').length).toBeGreaterThanOrEqual(1);
|
|
370
|
+
expect(calls('classroom', 'coursework', 'get').length).toBeGreaterThanOrEqual(1);
|
|
371
|
+
expect(calls('classroom', 'coursework', 'update')).toHaveLength(1);
|
|
372
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
373
|
+
['classroom', 'coursework', 'update', pos('c1'), pos('w1'), '--state=PUBLISHED', '--max-points=100'], { account: undefined });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('previews the NEW title and description when the same call replaces them, and a schedule as when it publishes', async () => {
|
|
377
|
+
const { h, details } = await prompted({ action: 'decline' });
|
|
378
|
+
await h.callTool('gog_classroom_coursework_update', { courseId: 'c1', courseworkId: 'w1', title: 'Chapter 5 problem set', description: 'Odd problems only', scheduled: '2026-10-01T12:00:00Z' });
|
|
379
|
+
expect(details()).toMatchObject({ coursework: { id: 'w1', title: 'Chapter 5 problem set', state: 'DRAFT' }, publishes: 'at 2026-10-01T12:00:00Z', descriptionPreview: 'Odd problems only' });
|
|
380
|
+
expect(calls('classroom', 'coursework', 'update')).toHaveLength(0);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('previews the CURRENT title and description when they are empty (gog keeps them: empty flags are never sent)', async () => {
|
|
384
|
+
const { h, details } = await prompted({ action: 'decline' });
|
|
385
|
+
await h.callTool('gog_classroom_coursework_update', { courseId: 'c1', courseworkId: 'w1', title: '', description: '', state: 'PUBLISHED' });
|
|
386
|
+
expect(details()).toMatchObject({ coursework: { id: 'w1', title: 'Chapter 4 problem set', state: 'DRAFT' }, descriptionPreview: 'Problems 1-20, show your work' });
|
|
387
|
+
expect(calls('classroom', 'coursework', 'update')).toHaveLength(0);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('token fallback binds the text students will see, not an ignored empty one', async () => {
|
|
391
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
392
|
+
const h = await createTestHarness(registerExtraClassroomTools);
|
|
393
|
+
const p1 = json(await h.callTool('gog_classroom_coursework_update', { courseId: 'c1', courseworkId: 'w1', title: '', description: '', state: 'PUBLISHED' }));
|
|
394
|
+
const p2 = json(await h.callTool('gog_classroom_coursework_update', { courseId: 'c1', courseworkId: 'w1', state: 'PUBLISHED' }));
|
|
395
|
+
expect(p1.preview).toEqual(p2.preview);
|
|
396
|
+
expect(p1.confirmToken).toBeDefined();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('does not ask for a due-date change', async () => {
|
|
400
|
+
const { h, messages } = await prompted();
|
|
401
|
+
await h.callTool('gog_classroom_coursework_update', { courseId: 'c1', courseworkId: 'w1', dueDate: '2026-10-03' });
|
|
402
|
+
expect(messages).toHaveLength(0);
|
|
403
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('refuses a client that cannot be prompted under MCP_CONFIRM_MODE=refuse', async () => {
|
|
407
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
408
|
+
const h = await createTestHarness(registerExtraClassroomTools);
|
|
409
|
+
const r = json(await h.callTool('gog_classroom_coursework_update', { courseId: 'c1', courseworkId: 'w1', state: 'PUBLISHED' }));
|
|
410
|
+
expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'classroom.coursework-publish' });
|
|
411
|
+
expect(calls('classroom', 'coursework', 'update')).toHaveLength(0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('token fallback: previews, then publishes on phase 2', async () => {
|
|
415
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
416
|
+
const h = await createTestHarness(registerExtraClassroomTools);
|
|
417
|
+
const args = { courseId: 'c1', courseworkId: 'w1', state: 'PUBLISHED' as const };
|
|
418
|
+
const p1 = json(await h.callTool('gog_classroom_coursework_update', args));
|
|
419
|
+
expect(p1).toMatchObject({ status: 'confirmation-required', preview: { coursework: { id: 'w1', title: 'Chapter 4 problem set', state: 'DRAFT' }, publishes: 'immediately', descriptionPreview: 'Problems 1-20, show your work' } });
|
|
420
|
+
// The teacher edits the coursework between the phases (updateTime rotates): the approval no longer names it.
|
|
421
|
+
stubReads({ 'classroom courses get': COURSE_JSON, 'classroom coursework get': COURSEWORK_JSON.replace('2026-09-21T10:00:00Z', '2026-09-22T08:00:00Z') });
|
|
422
|
+
expect(json(await h.callTool('gog_classroom_coursework_update', { ...args, confirmToken: p1.confirmToken }))).toMatchObject({ error: 'DRAFT_CHANGED' });
|
|
423
|
+
stubReads({ 'classroom courses get': COURSE_JSON, 'classroom coursework get': COURSEWORK_JSON });
|
|
424
|
+
await h.callTool('gog_classroom_coursework_update', { ...args, confirmToken: p1.confirmToken });
|
|
425
|
+
expect(calls('classroom', 'coursework', 'update')).toHaveLength(1);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('returns a failed course read without prompting or publishing', async () => {
|
|
429
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args) => {
|
|
430
|
+
const key = (args as unknown[]).map((a) => (typeof a === 'string' ? a : (a as { value: string }).value)).join(' ');
|
|
431
|
+
if (key.startsWith('classroom courses get')) return { content: [{ type: 'text', text: 'Error: not found' }], isError: true };
|
|
432
|
+
return rawTextResult(COURSEWORK_JSON);
|
|
433
|
+
});
|
|
434
|
+
const { h, messages } = await prompted();
|
|
435
|
+
const r = await h.callTool('gog_classroom_coursework_update', { courseId: 'c1', courseworkId: 'w1', state: 'PUBLISHED' });
|
|
436
|
+
expect(r.isError).toBe(true);
|
|
437
|
+
expect(messages).toHaveLength(0);
|
|
438
|
+
expect(calls('classroom', 'coursework', 'update')).toHaveLength(0);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('returns a failed coursework read without prompting or publishing', async () => {
|
|
442
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args) => {
|
|
443
|
+
const key = (args as unknown[]).map((a) => (typeof a === 'string' ? a : (a as { value: string }).value)).join(' ');
|
|
444
|
+
if (key.startsWith('classroom coursework get')) return { content: [{ type: 'text', text: 'Error: not found' }], isError: true };
|
|
445
|
+
return rawTextResult(COURSE_JSON);
|
|
446
|
+
});
|
|
447
|
+
const { h, messages } = await prompted();
|
|
448
|
+
const r = await h.callTool('gog_classroom_coursework_update', { courseId: 'c1', courseworkId: 'w1', state: 'PUBLISHED' });
|
|
449
|
+
expect(r.isError).toBe(true);
|
|
450
|
+
expect(messages).toHaveLength(0);
|
|
451
|
+
expect(calls('classroom', 'coursework', 'update')).toHaveLength(0);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
222
455
|
describe('gog_classroom_announcements_delete', () => {
|
|
223
456
|
it('calls runOrDiagnose with ids', async () => {
|
|
224
457
|
await harness.callTool('gog_classroom_announcements_delete', { courseId: 'c1', announcementId: 'a1' });
|