@oceanum/oceanumlab 4.7.1 → 4.7.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.
Files changed (45) hide show
  1. package/lib/DatameshWidget.js +14 -5
  2. package/lib/StoredNotebooks.d.ts +9 -5
  3. package/lib/StoredNotebooks.js +49 -21
  4. package/lib/auth/serverAuth.js +1 -0
  5. package/lib/auth/startSignIn.d.ts +11 -0
  6. package/lib/auth/startSignIn.js +16 -0
  7. package/lib/auth/tokens.d.ts +7 -0
  8. package/lib/index.d.ts +1 -1
  9. package/lib/index.js +8 -6
  10. package/lib/share/client.d.ts +84 -0
  11. package/lib/share/client.js +205 -0
  12. package/lib/share/events.d.ts +7 -0
  13. package/lib/share/events.js +9 -0
  14. package/lib/share/notebook.d.ts +184 -0
  15. package/lib/share/notebook.js +277 -0
  16. package/lib/share/oceanumOnly.d.ts +49 -0
  17. package/lib/share/oceanumOnly.js +90 -0
  18. package/lib/share/plugin.d.ts +21 -0
  19. package/lib/share/plugin.js +883 -0
  20. package/lib/share/widgets.d.ts +25 -0
  21. package/lib/share/widgets.js +154 -0
  22. package/package.json +5 -1
  23. package/src/DatameshWidget.tsx +21 -2
  24. package/src/StoredNotebooks.tsx +60 -20
  25. package/src/__tests__/OceanumPanel.spec.tsx +60 -4
  26. package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
  27. package/src/__tests__/oceanumOnly.spec.ts +193 -0
  28. package/src/__tests__/shareClient.spec.ts +314 -0
  29. package/src/__tests__/shareNotebook.spec.ts +451 -0
  30. package/src/__tests__/sharePlugin.spec.ts +426 -0
  31. package/src/auth/serverAuth.ts +1 -0
  32. package/src/auth/startSignIn.ts +23 -0
  33. package/src/auth/tokens.ts +7 -0
  34. package/src/index.ts +8 -6
  35. package/src/share/client.ts +288 -0
  36. package/src/share/events.ts +10 -0
  37. package/src/share/notebook.ts +416 -0
  38. package/src/share/oceanumOnly.ts +126 -0
  39. package/src/share/plugin.ts +1096 -0
  40. package/src/share/widgets.ts +217 -0
  41. package/style/index.css +111 -16
  42. package/lib/specStore.d.ts +0 -37
  43. package/lib/specStore.js +0 -57
  44. package/src/__tests__/specStore.spec.ts +0 -126
  45. package/src/specStore.ts +0 -82
@@ -0,0 +1,157 @@
1
+ import { ReactWidget } from '@jupyterlab/ui-components';
2
+ import { Signal } from '@lumino/signaling';
3
+ import { Widget } from '@lumino/widgets';
4
+ import React from 'react';
5
+
6
+ import { IOceanumAuth, IOceanumUser } from '../auth/tokens';
7
+ import { notebooksChanged } from '../share/events';
8
+ import { StoredNotebooks } from '../StoredNotebooks';
9
+
10
+ /** A signed-out auth that records whether sign-in was asked for. */
11
+ function signedOut(): IOceanumAuth & { signIns: number } {
12
+ const auth = {
13
+ signIns: 0,
14
+ environment: null as IOceanumAuth['environment'],
15
+ urls: { specs: 'https://specs.example.com' },
16
+ ready: Promise.resolve(),
17
+ user: null as IOceanumUser | null,
18
+ userChanged: new Signal<IOceanumAuth, IOceanumUser | null>(
19
+ {} as IOceanumAuth
20
+ ),
21
+ tokenChanged: new Signal<IOceanumAuth, string | null>({} as IOceanumAuth),
22
+ signIn: async (): Promise<void> => {
23
+ auth.signIns += 1;
24
+ },
25
+ signOut: async (): Promise<void> => undefined,
26
+ getAccessToken: async (): Promise<string | null> => null
27
+ };
28
+ return auth as unknown as IOceanumAuth & { signIns: number };
29
+ }
30
+
31
+ class Harness extends ReactWidget {
32
+ constructor(
33
+ private readonly _auth: IOceanumAuth,
34
+ private readonly _onSignIn?: () => void
35
+ ) {
36
+ super();
37
+ }
38
+
39
+ render(): React.ReactElement {
40
+ return <StoredNotebooks auth={this._auth} onSignIn={this._onSignIn} />;
41
+ }
42
+ }
43
+
44
+ async function mount(
45
+ auth: IOceanumAuth,
46
+ onSignIn?: () => void
47
+ ): Promise<Harness> {
48
+ const widget = new Harness(auth, onSignIn);
49
+ Widget.attach(widget, document.body);
50
+ const deadline = Date.now() + 4000;
51
+ for (;;) {
52
+ if (widget.node.querySelector('.oceanum-text-empty')) {
53
+ return widget;
54
+ }
55
+ if (Date.now() > deadline) {
56
+ throw new Error('StoredNotebooks rendered nothing within 4s');
57
+ }
58
+ await new Promise(resolve => setTimeout(resolve, 25));
59
+ }
60
+ }
61
+
62
+ describe('the Notebooks tab while signed in', () => {
63
+ const ID = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
64
+
65
+ it('lists again when a record is created, renamed or deleted', async () => {
66
+ const auth = signedOut();
67
+ (auth as { user: IOceanumUser | null }).user = {
68
+ sub: 'auth0|1',
69
+ email: 'me@example.com',
70
+ name: null,
71
+ activeOrg: null,
72
+ colorScheme: null
73
+ };
74
+ auth.getAccessToken = async () => 'tok';
75
+ const fetches: string[] = [];
76
+ const originalFetch = globalThis.fetch;
77
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
78
+ fetches.push(String(input));
79
+ return {
80
+ ok: true,
81
+ status: 200,
82
+ json: async () => [
83
+ {
84
+ id: ID,
85
+ name: 'Waves',
86
+ description: null as string | null,
87
+ modified: '2026-09-12T10:00:00',
88
+ creator: 'me@example.com'
89
+ }
90
+ ]
91
+ } as unknown as Response;
92
+ }) as typeof fetch;
93
+ try {
94
+ const widget = new Harness(auth);
95
+ Widget.attach(widget, document.body);
96
+ const until = async (test: () => boolean): Promise<void> => {
97
+ const deadline = Date.now() + 4000;
98
+ while (!test()) {
99
+ if (Date.now() > deadline) {
100
+ throw new Error('timed out');
101
+ }
102
+ await new Promise(resolve => setTimeout(resolve, 25));
103
+ }
104
+ };
105
+ await until(() => !!widget.node.querySelector('.oceanum-notebooks-item'));
106
+ expect(fetches).toHaveLength(1);
107
+ const row = widget.node.querySelector('.oceanum-notebooks-item')!;
108
+ expect(row.getAttribute('data-spec-id')).toBe(ID);
109
+
110
+ notebooksChanged.emit();
111
+
112
+ await until(() => fetches.length === 2);
113
+ widget.dispose();
114
+ } finally {
115
+ globalThis.fetch = originalFetch;
116
+ }
117
+ });
118
+ });
119
+
120
+ describe('the Notebooks tab while signed out', () => {
121
+ it('links "Sign in" to starting a sign-in', async () => {
122
+ const auth = signedOut();
123
+ const widget = await mount(auth);
124
+
125
+ expect(widget.node.textContent).toContain(
126
+ 'Sign in to Oceanum.io to see your notebooks.'
127
+ );
128
+ const link = widget.node.querySelector<HTMLAnchorElement>(
129
+ '.oceanum-text-empty a'
130
+ );
131
+ expect(link?.textContent).toBe('Sign in');
132
+
133
+ link!.click();
134
+ expect(auth.signIns).toBe(1);
135
+
136
+ widget.dispose();
137
+ });
138
+
139
+ it("prefers the host's own way of signing in when given one", async () => {
140
+ // On a JupyterLab server that is the device sign-in command, which shows the
141
+ // code; the auth's signIn() alone would start the flow with nothing to look at.
142
+ const auth = signedOut();
143
+ let hostSignIns = 0;
144
+ const widget = await mount(auth, () => {
145
+ hostSignIns += 1;
146
+ });
147
+
148
+ widget.node
149
+ .querySelector<HTMLAnchorElement>('.oceanum-text-empty a')!
150
+ .click();
151
+
152
+ expect(hostSignIns).toBe(1);
153
+ expect(auth.signIns).toBe(0);
154
+
155
+ widget.dispose();
156
+ });
157
+ });
@@ -0,0 +1,193 @@
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
+ pruneBeforeEachOpen,
11
+ pruneFileMenu,
12
+ routeSaves
13
+ } from '../share/oceanumOnly';
14
+
15
+ describe('hideFileBrowser', () => {
16
+ it('closes the file browser without disposing it, and leaves the rest', () => {
17
+ const left = new Panel();
18
+ const browser = new Widget();
19
+ browser.id = FILE_BROWSER_ID;
20
+ const running = new Widget();
21
+ running.id = 'jp-running-sessions';
22
+ left.addWidget(browser);
23
+ left.addWidget(running);
24
+
25
+ expect(hideFileBrowser({ widgets: () => left.widgets })).toBe(true);
26
+
27
+ expect(left.widgets).toEqual([running]);
28
+ // Other extensions still hold it through IDefaultFileBrowser.
29
+ expect(browser.isDisposed).toBe(false);
30
+ });
31
+
32
+ it('reports when there is none to hide', () => {
33
+ const left = new Panel();
34
+ expect(hideFileBrowser({ widgets: () => left.widgets })).toBe(false);
35
+ });
36
+ });
37
+
38
+ function fileMenu(commands: string[]): {
39
+ menu: Menu;
40
+ registry: CommandRegistry;
41
+ } {
42
+ const registry = new CommandRegistry();
43
+ const menu = new Menu({ commands: registry });
44
+ for (const command of commands) {
45
+ if (command === '-') {
46
+ menu.addItem({ type: 'separator' });
47
+ continue;
48
+ }
49
+ registry.addCommand(command, { execute: () => undefined, label: command });
50
+ menu.addItem({ command });
51
+ }
52
+ return { menu, registry };
53
+ }
54
+
55
+ const menuCommands = (menu: Menu): string[] =>
56
+ menu.items.map(item => (item.type === 'separator' ? '-' : item.command));
57
+
58
+ describe('pruneFileMenu', () => {
59
+ it('drops the local-file entries and keeps Save, Rename and everything else', () => {
60
+ const { menu } = fileMenu([
61
+ 'filebrowser:open-path',
62
+ 'filebrowser:open-url',
63
+ '-',
64
+ 'docmanager:save',
65
+ 'docmanager:save-as',
66
+ 'docmanager:save-all',
67
+ '-',
68
+ 'docmanager:reload',
69
+ 'docmanager:restore-checkpoint',
70
+ 'docmanager:rename',
71
+ 'docmanager:duplicate',
72
+ '-',
73
+ 'docmanager:download',
74
+ 'oceanum-share:save'
75
+ ]);
76
+
77
+ expect(pruneFileMenu(menu)).toBe(7);
78
+
79
+ expect(menuCommands(menu)).toEqual([
80
+ '-',
81
+ 'docmanager:save',
82
+ 'docmanager:save-all',
83
+ '-',
84
+ 'docmanager:rename',
85
+ '-',
86
+ 'oceanum-share:save'
87
+ ]);
88
+ // Idempotent: nothing left to remove.
89
+ expect(pruneFileMenu(menu)).toBe(0);
90
+ });
91
+
92
+ it('drops the Open Recent submenu, which the document manager builds by hand', () => {
93
+ const { menu, registry } = fileMenu(['docmanager:save']);
94
+ registry.addCommand('docmanager:clear-recents', {
95
+ execute: () => undefined,
96
+ label: 'Clear Recent'
97
+ });
98
+ const recents = new Menu({ commands: registry });
99
+ recents.title.label = 'Open Recent';
100
+ recents.addItem({ command: 'docmanager:clear-recents' });
101
+ menu.addItem({ type: 'submenu', submenu: recents });
102
+ const other = new Menu({ commands: registry });
103
+ other.title.label = 'New';
104
+ menu.addItem({ type: 'submenu', submenu: other });
105
+
106
+ expect(pruneFileMenu(menu)).toBe(1);
107
+
108
+ expect(menu.items.map(item => item.type)).toEqual(['command', 'submenu']);
109
+ expect(menu.items[1].submenu?.title.label).toBe('New');
110
+ });
111
+
112
+ it('names every entry it prunes, so the list is reviewable', () => {
113
+ expect(LOCAL_FILE_COMMANDS).toEqual([
114
+ 'filebrowser:open-path',
115
+ 'filebrowser:open-url',
116
+ 'docmanager:save-as',
117
+ 'docmanager:reload',
118
+ 'docmanager:restore-checkpoint',
119
+ 'docmanager:duplicate',
120
+ 'docmanager:download'
121
+ ]);
122
+ });
123
+ });
124
+
125
+ describe('pruneBeforeEachOpen', () => {
126
+ it('prunes entries that arrive after the first pass, each time the menu opens', () => {
127
+ const { menu, registry } = fileMenu(['docmanager:save']);
128
+ pruneBeforeEachOpen(menu);
129
+ // A plugin's settings schema loads late and adds its entry.
130
+ registry.addCommand('docmanager:save-as', {
131
+ execute: () => undefined,
132
+ label: 'Save As'
133
+ });
134
+ menu.addItem({ command: 'docmanager:save-as' });
135
+ expect(menuCommands(menu)).toEqual([
136
+ 'docmanager:save',
137
+ 'docmanager:save-as'
138
+ ]);
139
+
140
+ menu.open(0, 0);
141
+ try {
142
+ expect(menuCommands(menu)).toEqual(['docmanager:save']);
143
+ } finally {
144
+ menu.close();
145
+ menu.dispose();
146
+ }
147
+ });
148
+ });
149
+
150
+ /** Just enough of a notebook tracker and its panels to route saves. */
151
+ function fakeTracker(): {
152
+ tracker: INotebookTracker;
153
+ add: () => { panel: NotebookPanel; saveState: Signal<unknown, string> };
154
+ } {
155
+ const panels: NotebookPanel[] = [];
156
+ const widgetAdded = new Signal<INotebookTracker, NotebookPanel>(
157
+ {} as INotebookTracker
158
+ );
159
+ const tracker = {
160
+ forEach: (fn: (panel: NotebookPanel) => void) => panels.forEach(fn),
161
+ widgetAdded
162
+ } as unknown as INotebookTracker;
163
+ const add = () => {
164
+ const saveState = new Signal<unknown, string>({});
165
+ const panel = { context: { saveState } } as unknown as NotebookPanel;
166
+ panels.push(panel);
167
+ widgetAdded.emit(panel);
168
+ return { panel, saveState };
169
+ };
170
+ return { tracker, add };
171
+ }
172
+
173
+ describe('routeSaves', () => {
174
+ it('pushes after every completed save, of notebooks open now and later', () => {
175
+ const { tracker, add } = fakeTracker();
176
+ const first = add();
177
+ const pushed: NotebookPanel[] = [];
178
+ routeSaves(tracker, async panel => {
179
+ pushed.push(panel);
180
+ });
181
+
182
+ first.saveState.emit('started');
183
+ expect(pushed).toEqual([]);
184
+ first.saveState.emit('completed');
185
+ expect(pushed).toEqual([first.panel]);
186
+
187
+ const second = add();
188
+ second.saveState.emit('completed');
189
+ second.saveState.emit('failed');
190
+ first.saveState.emit('completed');
191
+ expect(pushed).toEqual([first.panel, second.panel, first.panel]);
192
+ });
193
+ });
@@ -0,0 +1,314 @@
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('refuses ids that are not UUIDs before building a URL', async () => {
99
+ const { client, fetch } = setup(() => json(record()));
100
+ await expect(client.get('../eidos')).rejects.toMatchObject({
101
+ kind: 'invalid'
102
+ });
103
+ await expect(client.update('x/permissions', body)).rejects.toBeInstanceOf(
104
+ SpecStoreError
105
+ );
106
+ await expect(client.remove('x')).rejects.toMatchObject({
107
+ kind: 'invalid'
108
+ });
109
+ expect(fetch).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('creates with a JSON body, never sends an id, and uses the server id', async () => {
113
+ const { client, call } = setup(() => json(record(SERVER_ID)));
114
+ const withId = { ...body, id: ID } as ISpecBody;
115
+ const result = await client.create(withId);
116
+ expect(result.id).toBe(SERVER_ID);
117
+ expect(call()).toEqual({
118
+ url: 'https://specs.example.com/specs/notebook',
119
+ method: 'POST',
120
+ headers: {
121
+ Authorization: 'Bearer tok',
122
+ 'Content-Type': 'application/json'
123
+ },
124
+ body: { name: 'Waves', description: null, spec: notebook }
125
+ });
126
+ });
127
+
128
+ it('updates with PUT to the record URL', async () => {
129
+ const { client, call } = setup(() => json(record()));
130
+ await client.update(ID, body);
131
+ expect(call().method).toBe('PUT');
132
+ expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
133
+ expect(call().body).toEqual(body);
134
+ });
135
+
136
+ it('patches only the fields it is given', async () => {
137
+ const { client, call } = setup(() => json(record()));
138
+ await client.patch(ID, { name: 'Renamed' });
139
+ expect(call()).toEqual({
140
+ url: `https://specs.example.com/specs/notebook/${ID}`,
141
+ method: 'PATCH',
142
+ headers: {
143
+ Authorization: 'Bearer tok',
144
+ 'Content-Type': 'application/json'
145
+ },
146
+ body: { name: 'Renamed' }
147
+ });
148
+ });
149
+
150
+ it('refuses a patch that carries nothing, without calling the server', async () => {
151
+ // The store answers an empty patch with 400, and there is nothing to send.
152
+ const { client, fetch } = setup(() => json(record()));
153
+ await expect(client.patch(ID, {})).rejects.toMatchObject({
154
+ kind: 'invalid'
155
+ });
156
+ expect(fetch).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('renames with a single patch, leaving the notebook alone', async () => {
160
+ const { client, call, fetch } = setup(() => json(record()));
161
+ await client.rename(ID, 'Renamed');
162
+ expect(fetch).toHaveBeenCalledTimes(1);
163
+ expect(call().method).toBe('PATCH');
164
+ expect(call().body).toEqual({ name: 'Renamed' });
165
+ });
166
+
167
+ it('renames by reading and putting back where the store has no PATCH', async () => {
168
+ // Production served no PATCH when this was written; a store without it answers 405.
169
+ const responses: Record<string, () => Response> = {
170
+ PATCH: () => json('Method Not Allowed', 405),
171
+ GET: () => json({ ...record(), description: 'Wave stats' }),
172
+ PUT: () => json(record())
173
+ };
174
+ const fetch = jest.fn(
175
+ async (_input: RequestInfo | URL, init?: RequestInit) =>
176
+ responses[init?.method ?? 'GET']()
177
+ );
178
+ const client = new SpecStoreClient({
179
+ specsUrl: 'https://specs.example.com',
180
+ getAccessToken: async () => 'tok',
181
+ fetch: fetch as unknown as typeof globalThis.fetch
182
+ });
183
+
184
+ await client.rename(ID, 'Renamed');
185
+
186
+ expect(fetch.mock.calls.map(([, init]) => init?.method)).toEqual([
187
+ 'PATCH',
188
+ 'GET',
189
+ 'PUT'
190
+ ]);
191
+ const put = JSON.parse(String(fetch.mock.calls[2][1]?.body));
192
+ // The name changes; everything else goes back as it was read.
193
+ expect(put).toEqual({
194
+ name: 'Renamed',
195
+ description: 'Wave stats',
196
+ spec: notebook
197
+ });
198
+ });
199
+
200
+ it('does not fall back for a failure that is not a missing method', async () => {
201
+ const { client, fetch } = setup(() => json('Forbidden', 403));
202
+ await expect(client.rename(ID, 'Renamed')).rejects.toMatchObject({
203
+ kind: 'forbidden'
204
+ });
205
+ expect(fetch).toHaveBeenCalledTimes(1);
206
+ });
207
+
208
+ it('deletes with DELETE to the record URL, and needs sign-in', async () => {
209
+ const { client, call } = setup(() => noContent());
210
+ await client.remove(ID);
211
+ expect(call().method).toBe('DELETE');
212
+ expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
213
+ expect(call().body).toBeUndefined();
214
+
215
+ const signedOut = setup(() => noContent(), null);
216
+ await expect(signedOut.client.remove(ID)).rejects.toMatchObject({
217
+ kind: 'signed-out'
218
+ });
219
+ expect(signedOut.fetch).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it('posts permission grants', async () => {
223
+ const { client, call } = setup(() => json([]));
224
+ await client.addPermission(ID, {
225
+ type: 'user',
226
+ entity: 'b@example.com',
227
+ permission: 'write'
228
+ });
229
+ await client.addPermission(ID, {
230
+ type: 'public',
231
+ entity: '',
232
+ permission: 'read'
233
+ });
234
+ expect(call(0).url).toBe(
235
+ `https://specs.example.com/specs/notebook/${ID}/permissions`
236
+ );
237
+ expect(call(0).method).toBe('POST');
238
+ expect(call(0).body).toEqual({
239
+ type: 'user',
240
+ entity: 'b@example.com',
241
+ permission: 'write'
242
+ });
243
+ expect(call(1).body).toEqual({
244
+ type: 'public',
245
+ entity: '',
246
+ permission: 'read'
247
+ });
248
+ });
249
+
250
+ it('deletes a permission by repeating the exact grant', async () => {
251
+ const { client, call } = setup(() => json([]));
252
+ await client.removePermission(ID, {
253
+ type: 'public',
254
+ entity: '',
255
+ permission: 'read'
256
+ });
257
+ expect(call(0).url).toBe(
258
+ `https://specs.example.com/specs/notebook/${ID}/permissions`
259
+ );
260
+ expect(call(0).method).toBe('DELETE');
261
+ expect(call(0).body).toEqual({
262
+ type: 'public',
263
+ entity: '',
264
+ permission: 'read'
265
+ });
266
+ });
267
+
268
+ it.each([
269
+ [400, 'expired', 'Your Oceanum session has expired, sign in again.'],
270
+ [403, 'forbidden', /another organisation/],
271
+ [404, 'not-found', /no longer exists/],
272
+ [405, 'unsupported', /does not offer that operation/],
273
+ [413, 'too-large', /too large/],
274
+ [500, 'server', 'Oceanum.io returned an error (HTTP 500).']
275
+ ])('maps HTTP %i to %s', async (status, kind, message) => {
276
+ const { client } = setup(() => json('Forbidden', status as number));
277
+ const error = await client.get(ID).catch(e => e);
278
+ expect(error).toBeInstanceOf(SpecStoreError);
279
+ expect(error.kind).toBe(kind);
280
+ expect(error.status).toBe(status);
281
+ expect(error.message).toMatch(message as string | RegExp);
282
+ });
283
+
284
+ it('maps a network failure', async () => {
285
+ const { client } = setup(() =>
286
+ Promise.reject(new TypeError('Failed to fetch'))
287
+ );
288
+ await expect(client.create(body)).rejects.toMatchObject({
289
+ kind: 'network',
290
+ message: expect.stringMatching(/Could not reach Oceanum.io/)
291
+ });
292
+ });
293
+
294
+ it('rejects malformed responses', async () => {
295
+ const { client } = setup(() => json({ id: 'nope', name: 1 }));
296
+ await expect(client.get(ID)).rejects.toMatchObject({ kind: 'invalid' });
297
+ const list = setup(() => json({ detail: 'x' }));
298
+ await expect(list.client.list()).rejects.toMatchObject({
299
+ kind: 'invalid'
300
+ });
301
+ });
302
+
303
+ it('asks for a fresh token on every request', async () => {
304
+ const getAccessToken = jest.fn(async () => 'tok');
305
+ const client = new SpecStoreClient({
306
+ specsUrl: 'https://specs.example.com',
307
+ getAccessToken,
308
+ fetch: async () => json(record())
309
+ });
310
+ await client.get(ID);
311
+ await client.get(ID);
312
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
313
+ });
314
+ });