@oceanum/oceanumlab 4.7.1 → 4.8.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.
Files changed (51) hide show
  1. package/lib/DatameshWidget.d.ts +11 -0
  2. package/lib/DatameshWidget.js +35 -5
  3. package/lib/StoredNotebooks.d.ts +27 -5
  4. package/lib/StoredNotebooks.js +141 -24
  5. package/lib/auth/serverAuth.js +1 -0
  6. package/lib/auth/startSignIn.d.ts +11 -0
  7. package/lib/auth/startSignIn.js +16 -0
  8. package/lib/auth/tokens.d.ts +13 -0
  9. package/lib/index.d.ts +1 -1
  10. package/lib/index.js +43 -7
  11. package/lib/share/client.d.ts +86 -0
  12. package/lib/share/client.js +206 -0
  13. package/lib/share/events.d.ts +7 -0
  14. package/lib/share/events.js +9 -0
  15. package/lib/share/notebook.d.ts +216 -0
  16. package/lib/share/notebook.js +355 -0
  17. package/lib/share/oceanumOnly.d.ts +72 -0
  18. package/lib/share/oceanumOnly.js +127 -0
  19. package/lib/share/plugin.d.ts +23 -0
  20. package/lib/share/plugin.js +981 -0
  21. package/lib/share/upload.d.ts +6 -0
  22. package/lib/share/upload.js +27 -0
  23. package/lib/share/widgets.d.ts +25 -0
  24. package/lib/share/widgets.js +154 -0
  25. package/package.json +5 -1
  26. package/schema/datamesh-connect.json +6 -0
  27. package/src/DatameshWidget.tsx +79 -2
  28. package/src/StoredNotebooks.tsx +274 -24
  29. package/src/__tests__/OceanumPanel.spec.tsx +159 -5
  30. package/src/__tests__/StoredNotebooks.spec.tsx +528 -0
  31. package/src/__tests__/oceanumOnly.spec.ts +317 -0
  32. package/src/__tests__/settingsSchema.spec.ts +11 -1
  33. package/src/__tests__/shareClient.spec.ts +328 -0
  34. package/src/__tests__/shareNotebook.spec.ts +624 -0
  35. package/src/__tests__/sharePlugin.spec.ts +664 -0
  36. package/src/auth/serverAuth.ts +1 -0
  37. package/src/auth/startSignIn.ts +23 -0
  38. package/src/auth/tokens.ts +13 -0
  39. package/src/index.ts +43 -7
  40. package/src/share/client.ts +291 -0
  41. package/src/share/events.ts +10 -0
  42. package/src/share/notebook.ts +525 -0
  43. package/src/share/oceanumOnly.ts +177 -0
  44. package/src/share/plugin.ts +1220 -0
  45. package/src/share/upload.ts +27 -0
  46. package/src/share/widgets.ts +217 -0
  47. package/style/index.css +212 -18
  48. package/lib/specStore.d.ts +0 -37
  49. package/lib/specStore.js +0 -57
  50. package/src/__tests__/specStore.spec.ts +0 -126
  51. package/src/specStore.ts +0 -82
@@ -0,0 +1,317 @@
1
+ import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
2
+ import { CommandRegistry } from '@lumino/commands';
3
+ import { Signal } from '@lumino/signaling';
4
+ import { Menu, Panel, Widget } from '@lumino/widgets';
5
+
6
+ import {
7
+ FILE_BROWSER_ID,
8
+ hideFileBrowser,
9
+ LOCAL_FILE_COMMANDS,
10
+ moveToTop,
11
+ NON_NOTEBOOK_NEW_COMMANDS,
12
+ OCEANUM_PANEL_ID,
13
+ pruneBeforeEachOpen,
14
+ pruneFileMenu,
15
+ routeSaves
16
+ } from '../share/oceanumOnly';
17
+
18
+ describe('hideFileBrowser', () => {
19
+ /** A left area holding the file browser and the running-sessions panel. */
20
+ function leftArea(): { left: Panel; browser: Widget; running: Widget } {
21
+ const left = new Panel();
22
+ const browser = new Widget();
23
+ browser.id = FILE_BROWSER_ID;
24
+ const running = new Widget();
25
+ running.id = 'jp-running-sessions';
26
+ left.addWidget(browser);
27
+ left.addWidget(running);
28
+ return { left, browser, running };
29
+ }
30
+
31
+ function shell(left: Panel) {
32
+ const activated: string[] = [];
33
+ return {
34
+ activated,
35
+ widgets: () => left.widgets,
36
+ activateById: (id: string): void => {
37
+ activated.push(id);
38
+ }
39
+ };
40
+ }
41
+
42
+ it('closes the file browser without disposing it, and leaves the rest', () => {
43
+ const { left, browser, running } = leftArea();
44
+
45
+ expect(hideFileBrowser(shell(left))).toBe(true);
46
+
47
+ expect(left.widgets).toEqual([running]);
48
+ // Other extensions still hold it through IDefaultFileBrowser.
49
+ expect(browser.isDisposed).toBe(false);
50
+ });
51
+
52
+ it('reports when there is none to hide', () => {
53
+ const left = new Panel();
54
+ expect(hideFileBrowser(shell(left))).toBe(false);
55
+ });
56
+
57
+ it('opens the replacement when the file browser was the open panel', () => {
58
+ const { left } = leftArea();
59
+ Widget.attach(left, document.body);
60
+ const fake = shell(left);
61
+
62
+ hideFileBrowser(fake, OCEANUM_PANEL_ID);
63
+
64
+ expect(fake.activated).toEqual([OCEANUM_PANEL_ID]);
65
+ Widget.detach(left);
66
+ });
67
+
68
+ it('leaves a collapsed sidebar collapsed', () => {
69
+ const { left, browser } = leftArea();
70
+ Widget.attach(left, document.body);
71
+ browser.hide();
72
+ const fake = shell(left);
73
+
74
+ hideFileBrowser(fake, OCEANUM_PANEL_ID);
75
+
76
+ expect(fake.activated).toEqual([]);
77
+ Widget.detach(left);
78
+ });
79
+ });
80
+
81
+ function fileMenu(commands: string[]): {
82
+ menu: Menu;
83
+ registry: CommandRegistry;
84
+ } {
85
+ const registry = new CommandRegistry();
86
+ const menu = new Menu({ commands: registry });
87
+ for (const command of commands) {
88
+ if (command === '-') {
89
+ menu.addItem({ type: 'separator' });
90
+ continue;
91
+ }
92
+ registry.addCommand(command, { execute: () => undefined, label: command });
93
+ menu.addItem({ command });
94
+ }
95
+ return { menu, registry };
96
+ }
97
+
98
+ const menuCommands = (menu: Menu): string[] =>
99
+ menu.items.map(item => (item.type === 'separator' ? '-' : item.command));
100
+
101
+ describe('pruneFileMenu', () => {
102
+ it('drops the local-file entries and keeps Save, Rename and everything else', () => {
103
+ const { menu } = fileMenu([
104
+ 'filebrowser:open-path',
105
+ 'filebrowser:open-url',
106
+ '-',
107
+ 'docmanager:save',
108
+ 'docmanager:save-as',
109
+ 'docmanager:save-all',
110
+ '-',
111
+ 'docmanager:reload',
112
+ 'docmanager:restore-checkpoint',
113
+ 'docmanager:rename',
114
+ 'docmanager:duplicate',
115
+ '-',
116
+ 'docmanager:download',
117
+ 'oceanum-share:save'
118
+ ]);
119
+
120
+ expect(pruneFileMenu(menu)).toBe(7);
121
+
122
+ expect(menuCommands(menu)).toEqual([
123
+ '-',
124
+ 'docmanager:save',
125
+ 'docmanager:save-all',
126
+ '-',
127
+ 'docmanager:rename',
128
+ '-',
129
+ 'oceanum-share:save'
130
+ ]);
131
+ // Idempotent: nothing left to remove.
132
+ expect(pruneFileMenu(menu)).toBe(0);
133
+ });
134
+
135
+ it('drops the Open Recent submenu, which the document manager builds by hand', () => {
136
+ const { menu, registry } = fileMenu(['docmanager:save']);
137
+ registry.addCommand('docmanager:clear-recents', {
138
+ execute: () => undefined,
139
+ label: 'Clear Recent'
140
+ });
141
+ const recents = new Menu({ commands: registry });
142
+ recents.title.label = 'Open Recent';
143
+ recents.addItem({ command: 'docmanager:clear-recents' });
144
+ menu.addItem({ type: 'submenu', submenu: recents });
145
+ const other = new Menu({ commands: registry });
146
+ other.title.label = 'New';
147
+ menu.addItem({ type: 'submenu', submenu: other });
148
+
149
+ expect(pruneFileMenu(menu)).toBe(1);
150
+
151
+ expect(menu.items.map(item => item.type)).toEqual(['command', 'submenu']);
152
+ expect(menu.items[1].submenu?.title.label).toBe('New');
153
+ });
154
+
155
+ it('names every entry it prunes, so the list is reviewable', () => {
156
+ expect(LOCAL_FILE_COMMANDS).toEqual([
157
+ 'filebrowser:open-path',
158
+ 'filebrowser:open-url',
159
+ 'docmanager:save-as',
160
+ 'docmanager:reload',
161
+ 'docmanager:restore-checkpoint',
162
+ 'docmanager:duplicate',
163
+ 'docmanager:download'
164
+ ]);
165
+ });
166
+ });
167
+
168
+ describe('pruneBeforeEachOpen', () => {
169
+ it('prunes entries that arrive after the first pass, each time the menu opens', () => {
170
+ const { menu, registry } = fileMenu(['docmanager:save']);
171
+ pruneBeforeEachOpen(menu);
172
+ // A plugin's settings schema loads late and adds its entry.
173
+ registry.addCommand('docmanager:save-as', {
174
+ execute: () => undefined,
175
+ label: 'Save As'
176
+ });
177
+ menu.addItem({ command: 'docmanager:save-as' });
178
+ expect(menuCommands(menu)).toEqual([
179
+ 'docmanager:save',
180
+ 'docmanager:save-as'
181
+ ]);
182
+
183
+ menu.open(0, 0);
184
+ try {
185
+ expect(menuCommands(menu)).toEqual(['docmanager:save']);
186
+ } finally {
187
+ menu.close();
188
+ menu.dispose();
189
+ }
190
+ });
191
+ });
192
+
193
+ /** Just enough of a notebook tracker and its panels to route saves. */
194
+ function fakeTracker(): {
195
+ tracker: INotebookTracker;
196
+ add: () => { panel: NotebookPanel; saveState: Signal<unknown, string> };
197
+ } {
198
+ const panels: NotebookPanel[] = [];
199
+ const widgetAdded = new Signal<INotebookTracker, NotebookPanel>(
200
+ {} as INotebookTracker
201
+ );
202
+ const tracker = {
203
+ forEach: (fn: (panel: NotebookPanel) => void) => panels.forEach(fn),
204
+ widgetAdded
205
+ } as unknown as INotebookTracker;
206
+ const add = () => {
207
+ const saveState = new Signal<unknown, string>({});
208
+ const panel = { context: { saveState } } as unknown as NotebookPanel;
209
+ panels.push(panel);
210
+ widgetAdded.emit(panel);
211
+ return { panel, saveState };
212
+ };
213
+ return { tracker, add };
214
+ }
215
+
216
+ describe('routeSaves', () => {
217
+ it('pushes after every completed save, of notebooks open now and later', () => {
218
+ const { tracker, add } = fakeTracker();
219
+ const first = add();
220
+ const pushed: NotebookPanel[] = [];
221
+ routeSaves(tracker, async panel => {
222
+ pushed.push(panel);
223
+ });
224
+
225
+ first.saveState.emit('started');
226
+ expect(pushed).toEqual([]);
227
+ first.saveState.emit('completed');
228
+ expect(pushed).toEqual([first.panel]);
229
+
230
+ const second = add();
231
+ second.saveState.emit('completed');
232
+ second.saveState.emit('failed');
233
+ first.saveState.emit('completed');
234
+ expect(pushed).toEqual([first.panel, second.panel, first.panel]);
235
+ });
236
+ });
237
+
238
+ describe('the File > New menu', () => {
239
+ it('loses the entries for files that are not notebooks', () => {
240
+ const { menu } = fileMenu([
241
+ 'console:create',
242
+ 'notebook:create-new',
243
+ ...NON_NOTEBOOK_NEW_COMMANDS
244
+ ]);
245
+
246
+ expect(pruneFileMenu(menu, NON_NOTEBOOK_NEW_COMMANDS)).toBe(2);
247
+
248
+ expect(menuCommands(menu)).toEqual([
249
+ 'console:create',
250
+ 'notebook:create-new'
251
+ ]);
252
+ });
253
+ });
254
+
255
+ describe('moveToTop', () => {
256
+ /** A left area in a saved order, and a shell that re-adds by rank as JupyterLab does. */
257
+ function sidebar(ids: string[]) {
258
+ const left = new Panel();
259
+ for (const id of ids) {
260
+ const widget = new Widget();
261
+ widget.id = id;
262
+ left.addWidget(widget);
263
+ }
264
+ Widget.attach(left, document.body);
265
+ const activated: string[] = [];
266
+ const added: [string, number][] = [];
267
+ const shell = {
268
+ widgets: () => left.widgets,
269
+ add: (widget: Widget, _: 'left', options: { rank: number }): void => {
270
+ added.push([widget.id, options.rank]);
271
+ widget.hide();
272
+ left.insertWidget(0, widget);
273
+ },
274
+ activateById: (id: string): void => {
275
+ activated.push(id);
276
+ }
277
+ };
278
+ const order = (): string[] => left.widgets.map(widget => widget.id);
279
+ const get = (id: string): Widget => left.widgets.find(w => w.id === id)!;
280
+ return { left, shell, activated, added, order, get };
281
+ }
282
+
283
+ it('puts a panel a returning user had last back in front, and reopens it', () => {
284
+ const bar = sidebar(['jp-running-sessions', OCEANUM_PANEL_ID]);
285
+ bar.get('jp-running-sessions').hide();
286
+
287
+ expect(moveToTop(bar.shell, OCEANUM_PANEL_ID)).toBe(true);
288
+
289
+ expect(bar.added).toEqual([[OCEANUM_PANEL_ID, 0]]);
290
+ expect(bar.order()).toEqual([OCEANUM_PANEL_ID, 'jp-running-sessions']);
291
+ expect(bar.activated).toEqual([OCEANUM_PANEL_ID]);
292
+ Widget.detach(bar.left);
293
+ });
294
+
295
+ it('does not open a panel that was closed', () => {
296
+ const bar = sidebar(['jp-running-sessions', OCEANUM_PANEL_ID]);
297
+ bar.get(OCEANUM_PANEL_ID).hide();
298
+
299
+ moveToTop(bar.shell, OCEANUM_PANEL_ID);
300
+
301
+ expect(bar.order()[0]).toBe(OCEANUM_PANEL_ID);
302
+ expect(bar.activated).toEqual([]);
303
+ Widget.detach(bar.left);
304
+ });
305
+
306
+ it('leaves the sidebar alone when the panel is already first or absent', () => {
307
+ const first = sidebar([OCEANUM_PANEL_ID, 'jp-running-sessions']);
308
+ expect(moveToTop(first.shell, OCEANUM_PANEL_ID)).toBe(false);
309
+ expect(first.added).toEqual([]);
310
+ Widget.detach(first.left);
311
+
312
+ const absent = sidebar(['jp-running-sessions']);
313
+ expect(moveToTop(absent.shell, OCEANUM_PANEL_ID)).toBe(false);
314
+ expect(absent.added).toEqual([]);
315
+ Widget.detach(absent.left);
316
+ });
317
+ });
@@ -44,10 +44,20 @@ describe('the datamesh-connect settings schema', () => {
44
44
  'datameshToken',
45
45
  'injectToken',
46
46
  'autoRunCode',
47
- 'iterate'
47
+ 'iterate',
48
+ 'showExamples'
48
49
  ]);
49
50
  });
50
51
 
52
+ it('shows the examples unless the user has hidden them', async () => {
53
+ // Settings stored before the switch existed must still load, showing examples.
54
+ const before = await registryWith({ datameshToken: 'a-token' }).load(ID);
55
+ expect(before.get('showExamples').composite).toBe(true);
56
+
57
+ const hidden = await registryWith({ showExamples: false }).load(ID);
58
+ expect(hidden.get('showExamples').composite).toBe(false);
59
+ });
60
+
51
61
  it('still loads settings stored when they were', async () => {
52
62
  const registry = registryWith({
53
63
  datameshToken: 'a-token',
@@ -0,0 +1,328 @@
1
+ import type { INotebookContent } from '@jupyterlab/nbformat';
2
+
3
+ import { SpecStoreClient, SpecStoreError } from '../share/client';
4
+ import type { ISpecBody } from '../share/notebook';
5
+
6
+ const ID = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
7
+ const SERVER_ID = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
8
+
9
+ const notebook: INotebookContent = {
10
+ nbformat: 4,
11
+ nbformat_minor: 5,
12
+ metadata: {},
13
+ cells: []
14
+ };
15
+
16
+ function record(id: string = ID) {
17
+ return {
18
+ id,
19
+ name: 'Waves',
20
+ description: null as string | null,
21
+ modified: '2026-09-12T10:00:00',
22
+ creator: 'a@example.com',
23
+ spec: notebook
24
+ };
25
+ }
26
+
27
+ function json(data: unknown, status = 200): Response {
28
+ return {
29
+ ok: status >= 200 && status < 300,
30
+ status,
31
+ json: async () => data
32
+ } as unknown as Response;
33
+ }
34
+
35
+ function noContent(): Response {
36
+ return {
37
+ ok: true,
38
+ status: 204,
39
+ json: async () => {
40
+ throw new Error('no body');
41
+ }
42
+ } as unknown as Response;
43
+ }
44
+
45
+ function setup(
46
+ response: () => Response | Promise<Response>,
47
+ token: string | null = 'tok'
48
+ ) {
49
+ const fetch = jest.fn(
50
+ async (_input: RequestInfo | URL, _init?: RequestInit) => response()
51
+ );
52
+ const client = new SpecStoreClient({
53
+ specsUrl: 'https://specs.example.com/',
54
+ getAccessToken: async () => token,
55
+ fetch: fetch as unknown as typeof globalThis.fetch
56
+ });
57
+ const call = (i = 0) => {
58
+ const [url, init] = fetch.mock.calls[i];
59
+ return {
60
+ url: String(url),
61
+ method: init?.method,
62
+ headers: (init?.headers ?? {}) as Record<string, string>,
63
+ body: init?.body === undefined ? undefined : JSON.parse(String(init.body))
64
+ };
65
+ };
66
+ return { client, fetch, call };
67
+ }
68
+
69
+ const body: ISpecBody = { name: 'Waves', description: null, spec: notebook };
70
+
71
+ describe('SpecStoreClient', () => {
72
+ it('lists with the bearer token', async () => {
73
+ const { client, call } = setup(() => json([record()]));
74
+ const items = await client.list();
75
+ expect(items).toHaveLength(1);
76
+ expect(call()).toEqual({
77
+ url: 'https://specs.example.com/specs/notebook',
78
+ method: 'GET',
79
+ headers: { Authorization: 'Bearer tok' },
80
+ body: undefined
81
+ });
82
+ });
83
+
84
+ it('requires sign-in to list, without calling the server', async () => {
85
+ const { client, fetch } = setup(() => json([]), null);
86
+ await expect(client.list()).rejects.toMatchObject({ kind: 'signed-out' });
87
+ expect(fetch).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('gets a record anonymously when signed out', async () => {
91
+ const { client, call } = setup(() => json(record()), null);
92
+ const result = await client.get(ID);
93
+ expect(result.id).toBe(ID);
94
+ expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
95
+ expect(call().headers).toEqual({});
96
+ });
97
+
98
+ it('reads another spec type when given one', async () => {
99
+ const fetch = jest.fn(async () => json(record()));
100
+ const client = new SpecStoreClient({
101
+ specsUrl: 'https://specs.example.com/',
102
+ getAccessToken: async () => 'tok',
103
+ specType: 'notebook-demo',
104
+ fetch: fetch as unknown as typeof globalThis.fetch
105
+ });
106
+ await client.get(ID);
107
+ expect(String((fetch.mock.calls[0] as unknown[])[0])).toBe(
108
+ `https://specs.example.com/specs/notebook-demo/${ID}`
109
+ );
110
+ });
111
+
112
+ it('refuses ids that are not UUIDs before building a URL', async () => {
113
+ const { client, fetch } = setup(() => json(record()));
114
+ await expect(client.get('../eidos')).rejects.toMatchObject({
115
+ kind: 'invalid'
116
+ });
117
+ await expect(client.update('x/permissions', body)).rejects.toBeInstanceOf(
118
+ SpecStoreError
119
+ );
120
+ await expect(client.remove('x')).rejects.toMatchObject({
121
+ kind: 'invalid'
122
+ });
123
+ expect(fetch).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('creates with a JSON body, never sends an id, and uses the server id', async () => {
127
+ const { client, call } = setup(() => json(record(SERVER_ID)));
128
+ const withId = { ...body, id: ID } as ISpecBody;
129
+ const result = await client.create(withId);
130
+ expect(result.id).toBe(SERVER_ID);
131
+ expect(call()).toEqual({
132
+ url: 'https://specs.example.com/specs/notebook',
133
+ method: 'POST',
134
+ headers: {
135
+ Authorization: 'Bearer tok',
136
+ 'Content-Type': 'application/json'
137
+ },
138
+ body: { name: 'Waves', description: null, spec: notebook }
139
+ });
140
+ });
141
+
142
+ it('updates with PUT to the record URL', async () => {
143
+ const { client, call } = setup(() => json(record()));
144
+ await client.update(ID, body);
145
+ expect(call().method).toBe('PUT');
146
+ expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
147
+ expect(call().body).toEqual(body);
148
+ });
149
+
150
+ it('patches only the fields it is given', async () => {
151
+ const { client, call } = setup(() => json(record()));
152
+ await client.patch(ID, { name: 'Renamed' });
153
+ expect(call()).toEqual({
154
+ url: `https://specs.example.com/specs/notebook/${ID}`,
155
+ method: 'PATCH',
156
+ headers: {
157
+ Authorization: 'Bearer tok',
158
+ 'Content-Type': 'application/json'
159
+ },
160
+ body: { name: 'Renamed' }
161
+ });
162
+ });
163
+
164
+ it('refuses a patch that carries nothing, without calling the server', async () => {
165
+ // The store answers an empty patch with 400, and there is nothing to send.
166
+ const { client, fetch } = setup(() => json(record()));
167
+ await expect(client.patch(ID, {})).rejects.toMatchObject({
168
+ kind: 'invalid'
169
+ });
170
+ expect(fetch).not.toHaveBeenCalled();
171
+ });
172
+
173
+ it('renames with a single patch, leaving the notebook alone', async () => {
174
+ const { client, call, fetch } = setup(() => json(record()));
175
+ await client.rename(ID, 'Renamed');
176
+ expect(fetch).toHaveBeenCalledTimes(1);
177
+ expect(call().method).toBe('PATCH');
178
+ expect(call().body).toEqual({ name: 'Renamed' });
179
+ });
180
+
181
+ it('renames by reading and putting back where the store has no PATCH', async () => {
182
+ // Production served no PATCH when this was written; a store without it answers 405.
183
+ const responses: Record<string, () => Response> = {
184
+ PATCH: () => json('Method Not Allowed', 405),
185
+ GET: () => json({ ...record(), description: 'Wave stats' }),
186
+ PUT: () => json(record())
187
+ };
188
+ const fetch = jest.fn(
189
+ async (_input: RequestInfo | URL, init?: RequestInit) =>
190
+ responses[init?.method ?? 'GET']()
191
+ );
192
+ const client = new SpecStoreClient({
193
+ specsUrl: 'https://specs.example.com',
194
+ getAccessToken: async () => 'tok',
195
+ fetch: fetch as unknown as typeof globalThis.fetch
196
+ });
197
+
198
+ await client.rename(ID, 'Renamed');
199
+
200
+ expect(fetch.mock.calls.map(([, init]) => init?.method)).toEqual([
201
+ 'PATCH',
202
+ 'GET',
203
+ 'PUT'
204
+ ]);
205
+ const put = JSON.parse(String(fetch.mock.calls[2][1]?.body));
206
+ // The name changes; everything else goes back as it was read.
207
+ expect(put).toEqual({
208
+ name: 'Renamed',
209
+ description: 'Wave stats',
210
+ spec: notebook
211
+ });
212
+ });
213
+
214
+ it('does not fall back for a failure that is not a missing method', async () => {
215
+ const { client, fetch } = setup(() => json('Forbidden', 403));
216
+ await expect(client.rename(ID, 'Renamed')).rejects.toMatchObject({
217
+ kind: 'forbidden'
218
+ });
219
+ expect(fetch).toHaveBeenCalledTimes(1);
220
+ });
221
+
222
+ it('deletes with DELETE to the record URL, and needs sign-in', async () => {
223
+ const { client, call } = setup(() => noContent());
224
+ await client.remove(ID);
225
+ expect(call().method).toBe('DELETE');
226
+ expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
227
+ expect(call().body).toBeUndefined();
228
+
229
+ const signedOut = setup(() => noContent(), null);
230
+ await expect(signedOut.client.remove(ID)).rejects.toMatchObject({
231
+ kind: 'signed-out'
232
+ });
233
+ expect(signedOut.fetch).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it('posts permission grants', async () => {
237
+ const { client, call } = setup(() => json([]));
238
+ await client.addPermission(ID, {
239
+ type: 'user',
240
+ entity: 'b@example.com',
241
+ permission: 'write'
242
+ });
243
+ await client.addPermission(ID, {
244
+ type: 'public',
245
+ entity: '',
246
+ permission: 'read'
247
+ });
248
+ expect(call(0).url).toBe(
249
+ `https://specs.example.com/specs/notebook/${ID}/permissions`
250
+ );
251
+ expect(call(0).method).toBe('POST');
252
+ expect(call(0).body).toEqual({
253
+ type: 'user',
254
+ entity: 'b@example.com',
255
+ permission: 'write'
256
+ });
257
+ expect(call(1).body).toEqual({
258
+ type: 'public',
259
+ entity: '',
260
+ permission: 'read'
261
+ });
262
+ });
263
+
264
+ it('deletes a permission by repeating the exact grant', async () => {
265
+ const { client, call } = setup(() => json([]));
266
+ await client.removePermission(ID, {
267
+ type: 'public',
268
+ entity: '',
269
+ permission: 'read'
270
+ });
271
+ expect(call(0).url).toBe(
272
+ `https://specs.example.com/specs/notebook/${ID}/permissions`
273
+ );
274
+ expect(call(0).method).toBe('DELETE');
275
+ expect(call(0).body).toEqual({
276
+ type: 'public',
277
+ entity: '',
278
+ permission: 'read'
279
+ });
280
+ });
281
+
282
+ it.each([
283
+ [400, 'expired', 'Your Oceanum session has expired, sign in again.'],
284
+ [403, 'forbidden', /another organisation/],
285
+ [404, 'not-found', /no longer exists/],
286
+ [405, 'unsupported', /does not offer that operation/],
287
+ [413, 'too-large', /too large/],
288
+ [500, 'server', 'Oceanum.io returned an error (HTTP 500).']
289
+ ])('maps HTTP %i to %s', async (status, kind, message) => {
290
+ const { client } = setup(() => json('Forbidden', status as number));
291
+ const error = await client.get(ID).catch(e => e);
292
+ expect(error).toBeInstanceOf(SpecStoreError);
293
+ expect(error.kind).toBe(kind);
294
+ expect(error.status).toBe(status);
295
+ expect(error.message).toMatch(message as string | RegExp);
296
+ });
297
+
298
+ it('maps a network failure', async () => {
299
+ const { client } = setup(() =>
300
+ Promise.reject(new TypeError('Failed to fetch'))
301
+ );
302
+ await expect(client.create(body)).rejects.toMatchObject({
303
+ kind: 'network',
304
+ message: expect.stringMatching(/Could not reach Oceanum.io/)
305
+ });
306
+ });
307
+
308
+ it('rejects malformed responses', async () => {
309
+ const { client } = setup(() => json({ id: 'nope', name: 1 }));
310
+ await expect(client.get(ID)).rejects.toMatchObject({ kind: 'invalid' });
311
+ const list = setup(() => json({ detail: 'x' }));
312
+ await expect(list.client.list()).rejects.toMatchObject({
313
+ kind: 'invalid'
314
+ });
315
+ });
316
+
317
+ it('asks for a fresh token on every request', async () => {
318
+ const getAccessToken = jest.fn(async () => 'tok');
319
+ const client = new SpecStoreClient({
320
+ specsUrl: 'https://specs.example.com',
321
+ getAccessToken,
322
+ fetch: async () => json(record())
323
+ });
324
+ await client.get(ID);
325
+ await client.get(ID);
326
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
327
+ });
328
+ });