@juppytt/fws 0.1.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.
- package/.claude/settings.local.json +72 -0
- package/README.md +126 -0
- package/bin/fws-cli.sh +4 -0
- package/bin/fws.ts +421 -0
- package/docs/cli-reference.md +211 -0
- package/docs/gws-support.md +276 -0
- package/package.json +28 -0
- package/src/config/rewrite-cache.ts +73 -0
- package/src/index.ts +3 -0
- package/src/proxy/mitm.ts +285 -0
- package/src/server/app.ts +26 -0
- package/src/server/middleware.ts +38 -0
- package/src/server/routes/calendar.ts +483 -0
- package/src/server/routes/control.ts +151 -0
- package/src/server/routes/drive.ts +342 -0
- package/src/server/routes/gmail.ts +758 -0
- package/src/server/routes/people.ts +239 -0
- package/src/server/routes/sheets.ts +242 -0
- package/src/server/routes/tasks.ts +191 -0
- package/src/store/index.ts +24 -0
- package/src/store/seed.ts +313 -0
- package/src/store/types.ts +225 -0
- package/src/util/id.ts +9 -0
- package/test/calendar.test.ts +227 -0
- package/test/drive.test.ts +153 -0
- package/test/gmail.test.ts +215 -0
- package/test/gws-validation.test.ts +883 -0
- package/test/helpers/harness.ts +109 -0
- package/test/snapshot.test.ts +80 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { createTestHarness, type TestHarness } from './helpers/harness.js';
|
|
3
|
+
|
|
4
|
+
describe('Calendar', () => {
|
|
5
|
+
let h: TestHarness;
|
|
6
|
+
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
h = await createTestHarness();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterAll(async () => {
|
|
12
|
+
await h.cleanup();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('calendarList', () => {
|
|
16
|
+
it('lists primary calendar', async () => {
|
|
17
|
+
const res = await h.fetch('/calendar/v3/users/me/calendarList');
|
|
18
|
+
const data = await res.json();
|
|
19
|
+
expect(data.items.length).toBeGreaterThan(0);
|
|
20
|
+
expect(data.items.some((c: any) => c.primary)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('lists via gws', async () => {
|
|
24
|
+
const { stdout, exitCode } = await h.gws('calendar calendarList list');
|
|
25
|
+
expect(exitCode).toBe(0);
|
|
26
|
+
const data = JSON.parse(stdout);
|
|
27
|
+
expect(data.items.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('calendars', () => {
|
|
32
|
+
it('creates a calendar', async () => {
|
|
33
|
+
const res = await h.fetch('/calendar/v3/calendars', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ summary: 'Work Calendar' }),
|
|
37
|
+
});
|
|
38
|
+
const cal = await res.json();
|
|
39
|
+
expect(cal.summary).toBe('Work Calendar');
|
|
40
|
+
expect(cal.id).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('gets calendar by id', async () => {
|
|
44
|
+
const listRes = await h.fetch('/calendar/v3/users/me/calendarList');
|
|
45
|
+
const list = await listRes.json();
|
|
46
|
+
const calId = list.items[0].id;
|
|
47
|
+
|
|
48
|
+
const res = await h.fetch(`/calendar/v3/calendars/${calId}`);
|
|
49
|
+
const cal = await res.json();
|
|
50
|
+
expect(cal.id).toBe(calId);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('patches calendar summary', async () => {
|
|
54
|
+
// Create then patch
|
|
55
|
+
const createRes = await h.fetch('/calendar/v3/calendars', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ summary: 'Old Name' }),
|
|
59
|
+
});
|
|
60
|
+
const cal = await createRes.json();
|
|
61
|
+
|
|
62
|
+
const patchRes = await h.fetch(`/calendar/v3/calendars/${cal.id}`, {
|
|
63
|
+
method: 'PATCH',
|
|
64
|
+
headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ summary: 'New Name' }),
|
|
66
|
+
});
|
|
67
|
+
const patched = await patchRes.json();
|
|
68
|
+
expect(patched.summary).toBe('New Name');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('deletes calendar and its events', async () => {
|
|
72
|
+
const createRes = await h.fetch('/calendar/v3/calendars', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({ summary: 'Delete Me' }),
|
|
76
|
+
});
|
|
77
|
+
const cal = await createRes.json();
|
|
78
|
+
|
|
79
|
+
const delRes = await h.fetch(`/calendar/v3/calendars/${cal.id}`, { method: 'DELETE' });
|
|
80
|
+
expect(delRes.status).toBe(204);
|
|
81
|
+
|
|
82
|
+
const getRes = await h.fetch(`/calendar/v3/calendars/${cal.id}`);
|
|
83
|
+
expect(getRes.status).toBe(404);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('events', () => {
|
|
88
|
+
it('lists seed events on primary calendar', async () => {
|
|
89
|
+
const res = await h.fetch('/calendar/v3/calendars/primary/events');
|
|
90
|
+
const data = await res.json();
|
|
91
|
+
expect(data.items.length).toBe(4);
|
|
92
|
+
expect(data.items.some((e: any) => e.id === 'evt001')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('lists events via gws', async () => {
|
|
96
|
+
const { stdout, exitCode } = await h.gws('calendar events list --params {"calendarId":"primary"}');
|
|
97
|
+
expect(exitCode).toBe(0);
|
|
98
|
+
const data = JSON.parse(stdout);
|
|
99
|
+
expect(data.items).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('creates an event', async () => {
|
|
103
|
+
const res = await h.fetch('/calendar/v3/calendars/primary/events', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'Content-Type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
summary: 'Team Meeting',
|
|
108
|
+
start: { dateTime: '2026-04-08T09:00:00Z' },
|
|
109
|
+
end: { dateTime: '2026-04-08T10:00:00Z' },
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
const event = await res.json();
|
|
113
|
+
expect(event.summary).toBe('Team Meeting');
|
|
114
|
+
expect(event.id).toBeTruthy();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('gets event by id', async () => {
|
|
118
|
+
// Create first
|
|
119
|
+
const createRes = await h.fetch('/calendar/v3/calendars/primary/events', {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/json' },
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
summary: 'Get Test',
|
|
124
|
+
start: { dateTime: '2026-04-09T14:00:00Z' },
|
|
125
|
+
end: { dateTime: '2026-04-09T15:00:00Z' },
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
const event = await createRes.json();
|
|
129
|
+
|
|
130
|
+
const getRes = await h.fetch(`/calendar/v3/calendars/primary/events/${event.id}`);
|
|
131
|
+
const got = await getRes.json();
|
|
132
|
+
expect(got.summary).toBe('Get Test');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('patches event', async () => {
|
|
136
|
+
const createRes = await h.fetch('/calendar/v3/calendars/primary/events', {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: { 'Content-Type': 'application/json' },
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
summary: 'Before Patch',
|
|
141
|
+
start: { dateTime: '2026-04-10T10:00:00Z' },
|
|
142
|
+
end: { dateTime: '2026-04-10T11:00:00Z' },
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
const event = await createRes.json();
|
|
146
|
+
|
|
147
|
+
const patchRes = await h.fetch(`/calendar/v3/calendars/primary/events/${event.id}`, {
|
|
148
|
+
method: 'PATCH',
|
|
149
|
+
headers: { 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify({ summary: 'After Patch' }),
|
|
151
|
+
});
|
|
152
|
+
const patched = await patchRes.json();
|
|
153
|
+
expect(patched.summary).toBe('After Patch');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('deletes event', async () => {
|
|
157
|
+
const createRes = await h.fetch('/calendar/v3/calendars/primary/events', {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
summary: 'Delete Event',
|
|
162
|
+
start: { dateTime: '2026-04-11T10:00:00Z' },
|
|
163
|
+
end: { dateTime: '2026-04-11T11:00:00Z' },
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
const event = await createRes.json();
|
|
167
|
+
|
|
168
|
+
const delRes = await h.fetch(`/calendar/v3/calendars/primary/events/${event.id}`, { method: 'DELETE' });
|
|
169
|
+
expect(delRes.status).toBe(204);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('filters events by timeMin/timeMax', async () => {
|
|
173
|
+
// Add events at different times
|
|
174
|
+
await h.fetch('/calendar/v3/calendars/primary/events', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
summary: 'Early Event',
|
|
179
|
+
start: { dateTime: '2026-01-01T10:00:00Z' },
|
|
180
|
+
end: { dateTime: '2026-01-01T11:00:00Z' },
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
await h.fetch('/calendar/v3/calendars/primary/events', {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
summary: 'Late Event',
|
|
188
|
+
start: { dateTime: '2026-12-01T10:00:00Z' },
|
|
189
|
+
end: { dateTime: '2026-12-01T11:00:00Z' },
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Query with timeMin that excludes Early
|
|
194
|
+
const res = await h.fetch('/calendar/v3/calendars/primary/events?timeMin=2026-06-01T00:00:00Z');
|
|
195
|
+
const data = await res.json();
|
|
196
|
+
const summaries = data.items.map((e: any) => e.summary);
|
|
197
|
+
expect(summaries).toContain('Late Event');
|
|
198
|
+
expect(summaries).not.toContain('Early Event');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('searches events by q', async () => {
|
|
202
|
+
const res = await h.fetch('/calendar/v3/calendars/primary/events?q=Team');
|
|
203
|
+
const data = await res.json();
|
|
204
|
+
expect(data.items.some((e: any) => e.summary?.includes('Team'))).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('creates and lists event via gws roundtrip', async () => {
|
|
208
|
+
// Setup event via control endpoint
|
|
209
|
+
const setup = await h.fetch('/__fws/setup/calendar/event', {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({
|
|
213
|
+
summary: 'GWS Roundtrip Event',
|
|
214
|
+
start: '2026-05-01T09:00:00Z',
|
|
215
|
+
duration: '1h',
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
const { id } = await setup.json();
|
|
219
|
+
|
|
220
|
+
// List via gws
|
|
221
|
+
const { stdout, exitCode } = await h.gws('calendar events list --params {"calendarId":"primary"}');
|
|
222
|
+
expect(exitCode).toBe(0);
|
|
223
|
+
const data = JSON.parse(stdout);
|
|
224
|
+
expect(data.items.some((e: any) => e.id === id)).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { createTestHarness, type TestHarness } from './helpers/harness.js';
|
|
3
|
+
|
|
4
|
+
describe('Drive', () => {
|
|
5
|
+
let h: TestHarness;
|
|
6
|
+
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
h = await createTestHarness();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterAll(async () => {
|
|
12
|
+
await h.cleanup();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('about', () => {
|
|
16
|
+
it('returns about info', async () => {
|
|
17
|
+
const res = await h.fetch('/drive/v3/about?fields=*');
|
|
18
|
+
const data = await res.json();
|
|
19
|
+
expect(data.kind).toBe('drive#about');
|
|
20
|
+
expect(data.user.emailAddress).toBe('testuser@example.com');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns about via gws', async () => {
|
|
24
|
+
const { stdout, exitCode } = await h.gws('drive about get --params {"fields":"*"}');
|
|
25
|
+
expect(exitCode).toBe(0);
|
|
26
|
+
const data = JSON.parse(stdout);
|
|
27
|
+
expect(data.kind).toBe('drive#about');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('files', () => {
|
|
32
|
+
it('lists seed files initially', async () => {
|
|
33
|
+
const res = await h.fetch('/drive/v3/files');
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
expect(data.files.length).toBe(5);
|
|
36
|
+
expect(data.files.some((f: any) => f.id === 'file001')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('creates a file', async () => {
|
|
40
|
+
const res = await h.fetch('/drive/v3/files', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ name: 'report.pdf', mimeType: 'application/pdf' }),
|
|
44
|
+
});
|
|
45
|
+
const file = await res.json();
|
|
46
|
+
expect(file.name).toBe('report.pdf');
|
|
47
|
+
expect(file.id).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('gets file by id', async () => {
|
|
51
|
+
const createRes = await h.fetch('/drive/v3/files', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ name: 'get-test.txt', mimeType: 'text/plain' }),
|
|
55
|
+
});
|
|
56
|
+
const file = await createRes.json();
|
|
57
|
+
|
|
58
|
+
const getRes = await h.fetch(`/drive/v3/files/${file.id}`);
|
|
59
|
+
const got = await getRes.json();
|
|
60
|
+
expect(got.name).toBe('get-test.txt');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('patches file name', async () => {
|
|
64
|
+
const createRes = await h.fetch('/drive/v3/files', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ name: 'old-name.txt' }),
|
|
68
|
+
});
|
|
69
|
+
const file = await createRes.json();
|
|
70
|
+
|
|
71
|
+
const patchRes = await h.fetch(`/drive/v3/files/${file.id}`, {
|
|
72
|
+
method: 'PATCH',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ name: 'new-name.txt' }),
|
|
75
|
+
});
|
|
76
|
+
const patched = await patchRes.json();
|
|
77
|
+
expect(patched.name).toBe('new-name.txt');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('deletes a file', async () => {
|
|
81
|
+
const createRes = await h.fetch('/drive/v3/files', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ name: 'delete-me.txt' }),
|
|
85
|
+
});
|
|
86
|
+
const file = await createRes.json();
|
|
87
|
+
|
|
88
|
+
const delRes = await h.fetch(`/drive/v3/files/${file.id}`, { method: 'DELETE' });
|
|
89
|
+
expect(delRes.status).toBe(204);
|
|
90
|
+
|
|
91
|
+
const getRes = await h.fetch(`/drive/v3/files/${file.id}`);
|
|
92
|
+
expect(getRes.status).toBe(404);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('copies a file', async () => {
|
|
96
|
+
const createRes = await h.fetch('/drive/v3/files', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({ name: 'original.txt', mimeType: 'text/plain' }),
|
|
100
|
+
});
|
|
101
|
+
const original = await createRes.json();
|
|
102
|
+
|
|
103
|
+
const copyRes = await h.fetch(`/drive/v3/files/${original.id}/copy`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'Content-Type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({ name: 'copied.txt' }),
|
|
107
|
+
});
|
|
108
|
+
const copy = await copyRes.json();
|
|
109
|
+
expect(copy.name).toBe('copied.txt');
|
|
110
|
+
expect(copy.id).not.toBe(original.id);
|
|
111
|
+
expect(copy.mimeType).toBe('text/plain');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('filters by q name contains', async () => {
|
|
115
|
+
const res = await h.fetch("/drive/v3/files?q=name%20contains%20'report'");
|
|
116
|
+
const data = await res.json();
|
|
117
|
+
expect(data.files.every((f: any) => f.name.toLowerCase().includes('report'))).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('filters by q mimeType', async () => {
|
|
121
|
+
const res = await h.fetch("/drive/v3/files?q=mimeType%20%3D%20'application/pdf'");
|
|
122
|
+
const data = await res.json();
|
|
123
|
+
expect(data.files.every((f: any) => f.mimeType === 'application/pdf')).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('creates and lists file via gws roundtrip', async () => {
|
|
127
|
+
// Setup via control endpoint
|
|
128
|
+
const setup = await h.fetch('/__fws/setup/drive/file', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify({ name: 'gws-test.pdf', mimeType: 'application/pdf' }),
|
|
132
|
+
});
|
|
133
|
+
const { id } = await setup.json();
|
|
134
|
+
|
|
135
|
+
// List via gws
|
|
136
|
+
const { stdout, exitCode } = await h.gws('drive files list');
|
|
137
|
+
expect(exitCode).toBe(0);
|
|
138
|
+
const data = JSON.parse(stdout);
|
|
139
|
+
expect(data.files.some((f: any) => f.id === id)).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('gets file via gws', async () => {
|
|
143
|
+
const listRes = await h.fetch('/drive/v3/files');
|
|
144
|
+
const list = await listRes.json();
|
|
145
|
+
const fileId = list.files[0].id;
|
|
146
|
+
|
|
147
|
+
const { stdout, exitCode } = await h.gws(`drive files get --params {"fileId":"${fileId}"}`);
|
|
148
|
+
expect(exitCode).toBe(0);
|
|
149
|
+
const file = JSON.parse(stdout);
|
|
150
|
+
expect(file.id).toBe(fileId);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { createTestHarness, type TestHarness } from './helpers/harness.js';
|
|
3
|
+
|
|
4
|
+
describe('Gmail', () => {
|
|
5
|
+
let h: TestHarness;
|
|
6
|
+
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
h = await createTestHarness();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterAll(async () => {
|
|
12
|
+
await h.cleanup();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('profile', () => {
|
|
16
|
+
it('returns test user profile via HTTP', async () => {
|
|
17
|
+
const res = await h.fetch('/gmail/v1/users/me/profile');
|
|
18
|
+
const data = await res.json();
|
|
19
|
+
expect(data.emailAddress).toBe('testuser@example.com');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns profile via gws', async () => {
|
|
23
|
+
const { stdout, exitCode } = await h.gws('gmail users getProfile --params {"userId":"me"}');
|
|
24
|
+
expect(exitCode).toBe(0);
|
|
25
|
+
const data = JSON.parse(stdout);
|
|
26
|
+
expect(data.emailAddress).toBe('testuser@example.com');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('labels', () => {
|
|
31
|
+
it('lists system labels', async () => {
|
|
32
|
+
const res = await h.fetch('/gmail/v1/users/me/labels');
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
expect(data.labels.length).toBeGreaterThanOrEqual(8);
|
|
35
|
+
expect(data.labels.map((l: any) => l.id)).toContain('INBOX');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('lists labels via gws', async () => {
|
|
39
|
+
const { stdout, exitCode } = await h.gws('gmail users labels list --params {"userId":"me"}');
|
|
40
|
+
expect(exitCode).toBe(0);
|
|
41
|
+
const data = JSON.parse(stdout);
|
|
42
|
+
expect(data.labels).toBeDefined();
|
|
43
|
+
expect(data.labels.map((l: any) => l.id)).toContain('INBOX');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('creates a user label', async () => {
|
|
47
|
+
const res = await h.fetch('/gmail/v1/users/me/labels', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ name: 'TestLabel' }),
|
|
51
|
+
});
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
expect(data.name).toBe('TestLabel');
|
|
54
|
+
expect(data.type).toBe('user');
|
|
55
|
+
expect(data.id).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('refuses to delete system labels', async () => {
|
|
59
|
+
const res = await h.fetch('/gmail/v1/users/me/labels/INBOX', { method: 'DELETE' });
|
|
60
|
+
expect(res.status).toBe(400);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('messages', () => {
|
|
65
|
+
it('lists seed messages initially', async () => {
|
|
66
|
+
const res = await h.fetch('/gmail/v1/users/me/messages');
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
expect(data.messages.length).toBe(5);
|
|
69
|
+
expect(data.messages.some((m: any) => m.id === 'msg001')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('setup adds a message then list returns it', async () => {
|
|
73
|
+
// Add via setup endpoint
|
|
74
|
+
const setup = await h.fetch('/__fws/setup/gmail/message', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
from: 'alice@example.com',
|
|
79
|
+
subject: 'Hello from Alice',
|
|
80
|
+
body: 'Test body content',
|
|
81
|
+
labels: ['INBOX', 'UNREAD'],
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
const { id } = await setup.json();
|
|
85
|
+
expect(id).toBeTruthy();
|
|
86
|
+
|
|
87
|
+
// List via gws
|
|
88
|
+
const { stdout, exitCode } = await h.gws('gmail users messages list --params {"userId":"me"}');
|
|
89
|
+
expect(exitCode).toBe(0);
|
|
90
|
+
const data = JSON.parse(stdout);
|
|
91
|
+
expect(data.messages.length).toBeGreaterThan(0);
|
|
92
|
+
expect(data.messages.some((m: any) => m.id === id)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('gets message with full format', async () => {
|
|
96
|
+
const res = await h.fetch('/gmail/v1/users/me/messages');
|
|
97
|
+
const list = await res.json();
|
|
98
|
+
const msgId = list.messages[0].id;
|
|
99
|
+
|
|
100
|
+
const res2 = await h.fetch(`/gmail/v1/users/me/messages/${msgId}`);
|
|
101
|
+
const msg = await res2.json();
|
|
102
|
+
expect(msg.id).toBe(msgId);
|
|
103
|
+
expect(msg.payload).toBeDefined();
|
|
104
|
+
expect(msg.payload.headers).toBeDefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('gets message via gws', async () => {
|
|
108
|
+
const listRes = await h.fetch('/gmail/v1/users/me/messages');
|
|
109
|
+
const list = await listRes.json();
|
|
110
|
+
const msgId = list.messages[0].id;
|
|
111
|
+
|
|
112
|
+
const { stdout, exitCode } = await h.gws(`gmail users messages get --params {"userId":"me","id":"${msgId}"}`);
|
|
113
|
+
expect(exitCode).toBe(0);
|
|
114
|
+
const msg = JSON.parse(stdout);
|
|
115
|
+
expect(msg.id).toBe(msgId);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('sends a message', async () => {
|
|
119
|
+
const raw = Buffer.from(
|
|
120
|
+
'From: testuser@example.com\r\nTo: bob@example.com\r\nSubject: Test Send\r\n\r\nHello Bob'
|
|
121
|
+
).toString('base64url');
|
|
122
|
+
|
|
123
|
+
const res = await h.fetch('/gmail/v1/users/me/messages/send', {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'Content-Type': 'application/json' },
|
|
126
|
+
body: JSON.stringify({ raw }),
|
|
127
|
+
});
|
|
128
|
+
const data = await res.json();
|
|
129
|
+
expect(data.id).toBeTruthy();
|
|
130
|
+
expect(data.labelIds).toContain('SENT');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('trashes and untrashes a message', async () => {
|
|
134
|
+
// Setup a message
|
|
135
|
+
const setup = await h.fetch('/__fws/setup/gmail/message', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ from: 'trash-test@example.com', subject: 'Trash me', body: 'x' }),
|
|
139
|
+
});
|
|
140
|
+
const { id } = await setup.json();
|
|
141
|
+
|
|
142
|
+
// Trash it
|
|
143
|
+
const trashRes = await h.fetch(`/gmail/v1/users/me/messages/${id}/trash`, { method: 'POST' });
|
|
144
|
+
const trashed = await trashRes.json();
|
|
145
|
+
expect(trashed.labelIds).toContain('TRASH');
|
|
146
|
+
expect(trashed.labelIds).not.toContain('INBOX');
|
|
147
|
+
|
|
148
|
+
// Untrash it
|
|
149
|
+
const untrashRes = await h.fetch(`/gmail/v1/users/me/messages/${id}/untrash`, { method: 'POST' });
|
|
150
|
+
const untrashed = await untrashRes.json();
|
|
151
|
+
expect(untrashed.labelIds).toContain('INBOX');
|
|
152
|
+
expect(untrashed.labelIds).not.toContain('TRASH');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('modifies message labels', async () => {
|
|
156
|
+
const setup = await h.fetch('/__fws/setup/gmail/message', {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'Content-Type': 'application/json' },
|
|
159
|
+
body: JSON.stringify({ from: 'modify@example.com', subject: 'Modify', body: 'x', labels: ['INBOX', 'UNREAD'] }),
|
|
160
|
+
});
|
|
161
|
+
const { id } = await setup.json();
|
|
162
|
+
|
|
163
|
+
const res = await h.fetch(`/gmail/v1/users/me/messages/${id}/modify`, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
166
|
+
body: JSON.stringify({ addLabelIds: ['STARRED'], removeLabelIds: ['UNREAD'] }),
|
|
167
|
+
});
|
|
168
|
+
const msg = await res.json();
|
|
169
|
+
expect(msg.labelIds).toContain('STARRED');
|
|
170
|
+
expect(msg.labelIds).not.toContain('UNREAD');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('deletes a message', async () => {
|
|
174
|
+
const setup = await h.fetch('/__fws/setup/gmail/message', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({ from: 'del@example.com', subject: 'Delete me', body: 'x' }),
|
|
178
|
+
});
|
|
179
|
+
const { id } = await setup.json();
|
|
180
|
+
|
|
181
|
+
const res = await h.fetch(`/gmail/v1/users/me/messages/${id}`, { method: 'DELETE' });
|
|
182
|
+
expect(res.status).toBe(204);
|
|
183
|
+
|
|
184
|
+
// Verify gone
|
|
185
|
+
const getRes = await h.fetch(`/gmail/v1/users/me/messages/${id}`);
|
|
186
|
+
expect(getRes.status).toBe(404);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('filters by q=from:alice', async () => {
|
|
190
|
+
const res = await h.fetch('/gmail/v1/users/me/messages?q=from:alice');
|
|
191
|
+
const data = await res.json();
|
|
192
|
+
// At least the alice message from earlier
|
|
193
|
+
expect(data.messages.length).toBeGreaterThan(0);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('threads', () => {
|
|
198
|
+
it('lists threads', async () => {
|
|
199
|
+
const res = await h.fetch('/gmail/v1/users/me/threads');
|
|
200
|
+
const data = await res.json();
|
|
201
|
+
expect(data.threads.length).toBeGreaterThan(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('gets a thread by id', async () => {
|
|
205
|
+
const listRes = await h.fetch('/gmail/v1/users/me/threads');
|
|
206
|
+
const list = await listRes.json();
|
|
207
|
+
const threadId = list.threads[0].id;
|
|
208
|
+
|
|
209
|
+
const res = await h.fetch(`/gmail/v1/users/me/threads/${threadId}`);
|
|
210
|
+
const thread = await res.json();
|
|
211
|
+
expect(thread.id).toBe(threadId);
|
|
212
|
+
expect(thread.messages.length).toBeGreaterThan(0);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|