gogcli-mcp 2.18.3 → 2.19.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,318 @@
1
+ import { describe, expect, it, afterEach, vi } from 'vitest';
2
+ import {
3
+ DEFAULT_DISPLAY_TZ,
4
+ displayTimeZone,
5
+ formatInstant,
6
+ isNaiveTimestamp,
7
+ naiveSourceTimeZone,
8
+ normalizeTimestamps,
9
+ parseTimestampValue,
10
+ } from '../src/timestamps.js';
11
+
12
+ const ET = 'America/New_York';
13
+
14
+ afterEach(() => {
15
+ vi.unstubAllEnvs();
16
+ });
17
+
18
+ describe('displayTimeZone', () => {
19
+ it('defaults to this deployment’s zone', () => {
20
+ expect(displayTimeZone()).toBe(DEFAULT_DISPLAY_TZ);
21
+ });
22
+
23
+ it('honours DISPLAY_TZ', () => {
24
+ vi.stubEnv('DISPLAY_TZ', 'America/Los_Angeles');
25
+ expect(displayTimeZone()).toBe('America/Los_Angeles');
26
+ });
27
+
28
+ it('falls back when DISPLAY_TZ is not a real IANA zone', () => {
29
+ vi.stubEnv('DISPLAY_TZ', 'Mars/Olympus_Mons');
30
+ expect(displayTimeZone()).toBe(DEFAULT_DISPLAY_TZ);
31
+ });
32
+ });
33
+
34
+ describe('formatInstant', () => {
35
+ // DST correctness comes from the IANA database, not a fixed offset: the same
36
+ // zone is -05:00 in January and -04:00 in July.
37
+ it('renders -05:00 in January and -04:00 in July', () => {
38
+ const jan = formatInstant(new Date('2026-01-15T17:00:00Z'), ET);
39
+ const jul = formatInstant(new Date('2026-07-15T17:00:00Z'), ET);
40
+ expect(jan.iso).toBe('2026-01-15T12:00:00-05:00');
41
+ expect(jul.iso).toBe('2026-07-15T13:00:00-04:00');
42
+ });
43
+
44
+ it('renders a positive offset east of UTC and +00:00 at UTC', () => {
45
+ expect(formatInstant(new Date('2026-07-15T17:00:00Z'), 'Asia/Tokyo').iso)
46
+ .toBe('2026-07-16T02:00:00+09:00');
47
+ expect(formatInstant(new Date('2026-07-15T17:00:00Z'), 'UTC').iso)
48
+ .toBe('2026-07-15T17:00:00+00:00');
49
+ });
50
+
51
+ it('pins midnight to hour 00 rather than 24', () => {
52
+ expect(formatInstant(new Date('2026-07-15T04:00:00Z'), ET).iso)
53
+ .toBe('2026-07-15T00:00:00-04:00');
54
+ });
55
+
56
+ it('handles a zone at a half-hour offset', () => {
57
+ expect(formatInstant(new Date('2026-07-15T00:00:00Z'), 'Asia/Kolkata').iso)
58
+ .toBe('2026-07-15T05:30:00+05:30');
59
+ });
60
+
61
+ it('includes the weekday, which is what makes a date-boundary error visible', () => {
62
+ const { display } = formatInstant(new Date('2026-07-28T03:36:00Z'), ET);
63
+ expect(display).toContain('Mon');
64
+ expect(display).toContain('Jul 27');
65
+ expect(display).toContain('11:36 PM');
66
+ });
67
+ });
68
+
69
+ describe('parseTimestampValue', () => {
70
+ it('treats Gmail internalDate as authoritative epoch milliseconds', () => {
71
+ const instant = parseTimestampValue('internalDate', '1785296160000', ET);
72
+ expect(instant?.toISOString()).toBe(new Date(1785296160000).toISOString());
73
+ });
74
+
75
+ it('interprets a naive wall-clock value in the configured zone', () => {
76
+ const instant = parseTimestampValue('date', '2026-07-27 23:36', ET);
77
+ expect(instant?.toISOString()).toBe('2026-07-28T03:36:00.000Z');
78
+ });
79
+
80
+ it('trusts an offset the source already carries', () => {
81
+ const instant = parseTimestampValue('sentAt', '2026-07-27T23:31:09-04:00', ET);
82
+ expect(instant?.toISOString()).toBe('2026-07-28T03:31:09.000Z');
83
+ });
84
+
85
+ // A bare date is a DATE (Calendar all-day events use it); converting one
86
+ // would invent a time the source never asserted.
87
+ it('leaves a date-only value alone', () => {
88
+ expect(parseTimestampValue('date', '2026-07-28', ET)).toBeNull();
89
+ });
90
+
91
+ it('ignores non-timestamp strings', () => {
92
+ expect(parseTimestampValue('date', 'not a date', ET)).toBeNull();
93
+ });
94
+
95
+ it('ignores a non-string value under a timestamp key', () => {
96
+ expect(parseTimestampValue('updated', 1785296160000, ET)).toBeNull();
97
+ expect(parseTimestampValue('updated', null, ET)).toBeNull();
98
+ });
99
+
100
+ // Shape-matching but not a real date: month 99 satisfies the regex's \d{2}
101
+ // yet Date rejects it. Must not produce an Invalid Date in the payload.
102
+ it('rejects a well-shaped but impossible date', () => {
103
+ expect(parseTimestampValue('sentAt', '2026-99-01T00:00:00Z', ET)).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe('normalizeTimestamps', () => {
108
+ // The reported failure: a 11:36 PM Eastern send read as 03:36 the NEXT day.
109
+ it('reports a late-evening send on the correct calendar day', () => {
110
+ const out = JSON.parse(normalizeTimestamps(
111
+ JSON.stringify({ messages: [{ id: 'm1', date: '2026-07-28 03:36' }] }),
112
+ 'UTC',
113
+ ));
114
+ // Source rendered in UTC; re-read in ET it must land on Jul 27.
115
+ const et = JSON.parse(normalizeTimestamps(
116
+ JSON.stringify({ messages: [{ id: 'm1', internalDate: String(Date.parse('2026-07-28T03:36:00Z')) }] }),
117
+ ET,
118
+ ));
119
+ expect(out.messages[0].date).toMatch(/[+-]\d{2}:\d{2}$|Z$/);
120
+ expect(et.messages[0].internalDate).toBe('2026-07-27T23:36:00-04:00');
121
+ expect(et.messages[0].internalDateDisplay).toContain('Mon, Jul 27');
122
+ });
123
+
124
+ it('never reports a 10:38 PM ET send on the following day', () => {
125
+ const sent = Date.parse('2026-07-28T02:38:00Z'); // 10:38 PM ET on Jul 27
126
+ const out = JSON.parse(normalizeTimestamps(
127
+ JSON.stringify({ internalDate: String(sent) }), ET,
128
+ ));
129
+ expect(out.internalDate.startsWith('2026-07-27')).toBe(true);
130
+ expect(out.internalDateDisplay).toContain('Jul 27');
131
+ });
132
+
133
+ it('adds an explicit offset and a display sibling to every allowlisted field', () => {
134
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify({
135
+ files: [{ modifiedTime: '2026-07-28T03:36:00Z', createdTime: '2026-07-01T12:00:00Z' }],
136
+ }), ET));
137
+ expect(out.files[0].modifiedTime).toBe('2026-07-27T23:36:00-04:00');
138
+ expect(out.files[0].modifiedTimeDisplay).toContain('Mon, Jul 27');
139
+ expect(out.files[0].createdTimeDisplay).toBeDefined();
140
+ });
141
+
142
+ it('recurses into nested Calendar structures', () => {
143
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify({
144
+ items: [{ start: { dateTime: '2026-07-28T03:36:00Z', timeZone: 'America/New_York' } }],
145
+ }), ET));
146
+ expect(out.items[0].start.dateTime).toBe('2026-07-27T23:36:00-04:00');
147
+ expect(out.items[0].start.dateTimeDisplay).toContain('Jul 27');
148
+ // A zone NAME is not an instant and must survive untouched.
149
+ expect(out.items[0].start.timeZone).toBe('America/New_York');
150
+ });
151
+
152
+ // The near-miss names are the real hazard: a name-pattern match would
153
+ // rewrite spreadsheet cell data.
154
+ it('leaves near-miss keys and cell values alone', () => {
155
+ const payload = {
156
+ updatedCells: 5,
157
+ updatedRange: 'Sheet1!A1:B2',
158
+ updatedRows: 2,
159
+ formattedValue: '2026-07-28 03:36',
160
+ verificationStatus: 'accepted',
161
+ values: [['2026-07-28 03:36']],
162
+ };
163
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify(payload), ET));
164
+ expect(out).toEqual(payload);
165
+ });
166
+
167
+ it('passes non-JSON output through untouched', () => {
168
+ expect(normalizeTimestamps('Error: something failed', ET)).toBe('Error: something failed');
169
+ expect(normalizeTimestamps('', ET)).toBe('');
170
+ expect(normalizeTimestamps('not json {', ET)).toBe('not json {');
171
+ expect(normalizeTimestamps('"a string"', ET)).toBe('"a string"');
172
+ expect(normalizeTimestamps('{bad json', ET)).toBe('{bad json');
173
+ });
174
+
175
+ it('is idempotent — re-normalizing changes nothing', () => {
176
+ const once = normalizeTimestamps(JSON.stringify({ date: '2026-07-27 23:36' }), ET);
177
+ expect(normalizeTimestamps(once, ET)).toBe(once);
178
+ });
179
+
180
+ // Contract test: nothing emitted may lack an offset or Z.
181
+ it('emits no naive timestamp anywhere in the payload', () => {
182
+ const out = normalizeTimestamps(JSON.stringify({
183
+ a: { date: '2026-07-28 03:36' },
184
+ b: [{ sentAt: '2026-07-27T23:31:09' }],
185
+ c: { fetchedBodyAt: '2026-07-28T12:11:19.106Z' },
186
+ }), ET);
187
+ const parsed = JSON.parse(out);
188
+ const naive: string[] = [];
189
+ const scan = (n: unknown): void => {
190
+ if (Array.isArray(n)) return void n.forEach(scan);
191
+ if (n && typeof n === 'object') return void Object.values(n).forEach(scan);
192
+ if (isNaiveTimestamp(n)) naive.push(String(n));
193
+ };
194
+ scan(parsed);
195
+ expect(naive).toEqual([]);
196
+ });
197
+
198
+ // Mixed-zone assertion: one object must not carry both naive and
199
+ // offset-bearing values.
200
+ it('never mixes naive and offset-bearing timestamps in one object', () => {
201
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify({
202
+ sentAt: '2026-07-27T23:31:09',
203
+ fetchedBodyAt: '2026-07-28T12:11:19.106Z',
204
+ asOf: '2026-07-28T20:27:11.426Z',
205
+ }), ET));
206
+ const values = [out.sentAt, out.fetchedBodyAt, out.asOf];
207
+ expect(values.every((v: string) => /([+-]\d{2}:\d{2}|Z)$/.test(v))).toBe(true);
208
+ expect(values.some(isNaiveTimestamp)).toBe(false);
209
+ });
210
+
211
+ it('keeps contemporaneous events in order and on the same day', () => {
212
+ const base = Date.parse('2026-07-28T03:31:09Z');
213
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify({
214
+ ofw: { sentAt: '2026-07-27T23:31:09' },
215
+ gmail: { internalDate: String(base + 5 * 60_000) },
216
+ }), ET));
217
+ expect(out.ofw.sentAt).toBe('2026-07-27T23:31:09-04:00');
218
+ expect(out.gmail.internalDate).toBe('2026-07-27T23:36:09-04:00');
219
+ expect(out.ofw.sentAtDisplay).toContain('Jul 27');
220
+ expect(out.gmail.internalDateDisplay).toContain('Jul 27');
221
+ });
222
+
223
+ it('DISPLAY_TZ shifts display fields and the offset, nothing else', () => {
224
+ const payload = JSON.stringify({ id: 'm1', subject: 'S', internalDate: '1785296160000' });
225
+ const et = JSON.parse(normalizeTimestamps(payload, ET));
226
+ const pt = JSON.parse(normalizeTimestamps(payload, 'America/Los_Angeles'));
227
+ expect(et.internalDate).not.toBe(pt.internalDate);
228
+ expect(et.internalDateDisplay).not.toBe(pt.internalDateDisplay);
229
+ // Same instant either way.
230
+ expect(Date.parse(et.internalDate)).toBe(Date.parse(pt.internalDate));
231
+ // Non-timestamp fields are untouched by the zone.
232
+ expect(pt.id).toBe('m1');
233
+ expect(pt.subject).toBe('S');
234
+ });
235
+
236
+ // In UTC, longOffset renders a bare "GMT" with no numeric part, and a naive
237
+ // wall time needs no correction at all.
238
+ it('renders UTC as +00:00 with no drift correction', () => {
239
+ const out = JSON.parse(normalizeTimestamps(
240
+ JSON.stringify({ date: '2026-07-28 03:36' }), 'UTC', 'UTC',
241
+ ));
242
+ expect(out.date).toBe('2026-07-28T03:36:00+00:00');
243
+ expect(out.dateDisplay).toContain('Jul 28');
244
+ });
245
+
246
+ it('preserves sub-second precision on a naive value', () => {
247
+ const out = JSON.parse(normalizeTimestamps(
248
+ JSON.stringify({ fetchedBodyAt: '2026-07-28T12:11:19.106' }), 'UTC', 'UTC',
249
+ ));
250
+ expect(out.fetchedBodyAt).toBe('2026-07-28T12:11:19.106+00:00');
251
+ });
252
+
253
+ it('normalizes a top-level array', () => {
254
+ const out = JSON.parse(normalizeTimestamps(
255
+ JSON.stringify([{ internalDate: '1785296160000' }]), ET,
256
+ ));
257
+ expect(out[0].internalDate).toMatch(/[+-]\d{2}:\d{2}$/);
258
+ });
259
+
260
+ it('leaves an allowlisted key alone when its value is not a timestamp', () => {
261
+ const payload = { date: 'sometime last week', updated: '' };
262
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify(payload), ET));
263
+ expect(out).toEqual(payload);
264
+ });
265
+
266
+ // Re-serializing a response that carries no timestamps would silently reflow
267
+ // it — including flattening a caller's --pretty formatting.
268
+ it('returns the original text byte-for-byte when nothing was rewritten', () => {
269
+ const pretty = '{\n "id": "m1",\n "subject": "S"\n}';
270
+ expect(normalizeTimestamps(pretty, ET)).toBe(pretty);
271
+ });
272
+
273
+ it('preserves the caller’s pretty indentation when it does rewrite', () => {
274
+ const pretty = '{\n "id": "m1",\n "date": "2026-07-27 23:36"\n}';
275
+ const out = normalizeTimestamps(pretty, ET);
276
+ expect(out).toContain('\n "date"');
277
+ expect(JSON.parse(out).date).toBe('2026-07-27T23:36:00-04:00');
278
+ });
279
+
280
+ // Date.UTC rolls impossible components over instead of rejecting them, so a
281
+ // typo would surface as a confident wrong date.
282
+ it('rejects impossible naive dates rather than rolling them over', () => {
283
+ for (const bad of ['2026-13-05T10:00:00', '2026-02-30T10:00:00', '2026-01-01T25:00:00']) {
284
+ expect(parseTimestampValue('date', bad, ET)).toBeNull();
285
+ }
286
+ });
287
+
288
+ // 10 digits is epoch SECONDS; reading it as milliseconds dates it to 1970.
289
+ it('only treats a 13-digit internalDate as epoch milliseconds', () => {
290
+ expect(parseTimestampValue('internalDate', '1785209760', ET)).toBeNull();
291
+ expect(parseTimestampValue('internalDate', '1785209760000', ET)).not.toBeNull();
292
+ });
293
+
294
+ it('reads the naive-source zone from GOG_TIMEZONE, independent of DISPLAY_TZ', () => {
295
+ vi.stubEnv('GOG_TIMEZONE', 'UTC');
296
+ vi.stubEnv('DISPLAY_TZ', 'America/New_York');
297
+ // gog formatted this in UTC; it must be read as UTC and displayed in ET.
298
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify({ date: '2026-07-28 03:36' })));
299
+ expect(out.date).toBe('2026-07-27T23:36:00-04:00');
300
+ expect(out.dateDisplay).toContain('Jul 27');
301
+ });
302
+
303
+ it('naiveSourceTimeZone falls back to the display zone', () => {
304
+ vi.stubEnv('DISPLAY_TZ', 'America/Los_Angeles');
305
+ expect(naiveSourceTimeZone()).toBe('America/Los_Angeles');
306
+ vi.stubEnv('GOG_TIMEZONE', 'Mars/Olympus_Mons');
307
+ expect(naiveSourceTimeZone()).toBe('America/Los_Angeles');
308
+ });
309
+
310
+ it('handles a DST spring-forward wall time without drifting a day', () => {
311
+ // 2026-03-08 02:30 ET does not exist (clocks jump 02:00 -> 03:00).
312
+ const out = JSON.parse(normalizeTimestamps(
313
+ JSON.stringify({ date: '2026-03-08 02:30' }), ET,
314
+ ));
315
+ expect(out.date.startsWith('2026-03-08')).toBe(true);
316
+ expect(out.date).toMatch(/[+-]\d{2}:\d{2}$/);
317
+ });
318
+ });
@@ -95,6 +95,25 @@ describe('runOrDiagnose', () => {
95
95
  expect(result.isError).toBeUndefined();
96
96
  });
97
97
 
98
+ // The `*_raw` dumps promise a verbatim copy of the upstream API response.
99
+ // Normalizing them would rewrite the API's own epoch-millis internalDate into
100
+ // an ISO string and flatten the caller's --pretty formatting, so the one tool
101
+ // you reach for when you need ground truth would stop telling it.
102
+ it('leaves a lossless response byte-for-byte untouched', async () => {
103
+ const raw = '{\n "id": "m1",\n "internalDate": "1785209760000"\n}';
104
+ vi.mocked(runner.run).mockResolvedValue(raw);
105
+ const result = await runOrDiagnose(['gmail', 'raw', 'm1'], { lossless: true });
106
+ expect(result.content[0].text).toBe(raw);
107
+ });
108
+
109
+ it('normalizes timestamps when lossless is not set', async () => {
110
+ vi.mocked(runner.run).mockResolvedValue('{"internalDate":"1785209760000"}');
111
+ const result = await runOrDiagnose(['gmail', 'messages'], {});
112
+ const parsed = JSON.parse(result.content[0].text as string);
113
+ expect(parsed.internalDate).toMatch(/[+-]\d{2}:\d{2}$/);
114
+ expect(parsed.internalDateDisplay).toBeDefined();
115
+ });
116
+
98
117
  it('appends auth list on non-auth failure', async () => {
99
118
  vi.mocked(runner.run)
100
119
  .mockRejectedValueOnce(new Error('Doc not found'))