docpouch-client 0.9.1 → 1.0.3
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/README.md +630 -449
- package/chat-09e440bd-c52c-44b9-bba4-01cebc84bd7b.txt +223 -0
- package/dist/index.d.ts +156 -5
- package/dist/index.js +344 -10
- package/hff +114 -0
- package/{jest.config.js → jest.config.cjs} +1 -1
- package/package.json +41 -38
- package/src/index.ts +1045 -622
- package/tests/docPouchClient.test.ts +654 -0
- package/tests/mock-server.ts +613 -0
- package/tsconfig.build.json +7 -0
- package/tsconfig.json +9 -7
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { jest, describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
|
|
2
|
+
import docPouchClient from '../src/index.js';
|
|
3
|
+
import type {
|
|
4
|
+
I_DocumentEntry, I_DocumentQuery, I_UserEntry, I_DataStructure,
|
|
5
|
+
} from '../src/index.js';
|
|
6
|
+
import { createMockServer, type MockServer } from './mock-server.js';
|
|
7
|
+
|
|
8
|
+
const mockWindow = {
|
|
9
|
+
location: { href: '', search: '', pathname: '' },
|
|
10
|
+
history: { replaceState: jest.fn<any>(), pushState: jest.fn<any>() },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
if (typeof (globalThis as any).window === 'undefined') {
|
|
14
|
+
(globalThis as any).window = mockWindow;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
jest.setTimeout(15000);
|
|
18
|
+
|
|
19
|
+
function waitForSocket(client: docPouchClient, timeout = 5000): Promise<void> {
|
|
20
|
+
if (client.socket.connected) return Promise.resolve();
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const timer = setTimeout(() => reject(new Error('Socket connection timeout')), timeout);
|
|
23
|
+
client.socket.once('connect', () => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
resolve();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function delay(ms: number): Promise<void> {
|
|
31
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let mockServer: MockServer;
|
|
35
|
+
const adminLogin = { name: 'admin', password: 'admin' };
|
|
36
|
+
const userLogin = { name: 'testuser', password: 'testpass' };
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
mockServer = await createMockServer();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
mockServer.io.sockets.sockets.forEach(s => s.disconnect());
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
mockServer.io.close();
|
|
48
|
+
mockServer.server.close();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
mockServer.reset();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ─── Constructor & Basic Properties ─────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe('constructor', () => {
|
|
58
|
+
it('should create a client with the given URL', () => {
|
|
59
|
+
const client = new docPouchClient(mockServer.url);
|
|
60
|
+
expect(client.baseUrl).toBe(mockServer.url);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should have a socket instance', () => {
|
|
64
|
+
const client = new docPouchClient(mockServer.url);
|
|
65
|
+
expect(client.socket).toBeDefined();
|
|
66
|
+
expect(client.socket.connected).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should not be connected initially', () => {
|
|
70
|
+
const client = new docPouchClient(mockServer.url);
|
|
71
|
+
expect(client.realTimeSync).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should return the package version', () => {
|
|
75
|
+
const client = new docPouchClient(mockServer.url);
|
|
76
|
+
const version = client.getVersion();
|
|
77
|
+
expect(typeof version).toBe('string');
|
|
78
|
+
expect(version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ─── Authentication ─────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
describe('authentication', () => {
|
|
85
|
+
it('should login with valid admin credentials', async () => {
|
|
86
|
+
const client = new docPouchClient(mockServer.url);
|
|
87
|
+
const result = await client.login(adminLogin);
|
|
88
|
+
expect(result).not.toBeNull();
|
|
89
|
+
expect(result!.token).toBeDefined();
|
|
90
|
+
expect(result!.isAdmin).toBe(true);
|
|
91
|
+
expect(result!.userName).toBe('admin');
|
|
92
|
+
expect(result!._id).toBeDefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should login with valid user credentials', async () => {
|
|
96
|
+
const client = new docPouchClient(mockServer.url);
|
|
97
|
+
const result = await client.login(userLogin);
|
|
98
|
+
expect(result).not.toBeNull();
|
|
99
|
+
expect(result!.token).toBeDefined();
|
|
100
|
+
expect(result!.isAdmin).toBe(false);
|
|
101
|
+
expect(result!.userName).toBe('testuser');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should throw for invalid credentials', async () => {
|
|
105
|
+
const client = new docPouchClient(mockServer.url);
|
|
106
|
+
await expect(client.login({ name: 'admin', password: 'wrong' })).rejects.toThrow('API error: 401');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should throw for non-existent user', async () => {
|
|
110
|
+
const client = new docPouchClient(mockServer.url);
|
|
111
|
+
await expect(client.login({ name: 'nobody', password: 'pass' })).rejects.toThrow('API error: 404');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ─── Token Management ───────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
describe('token management', () => {
|
|
118
|
+
it('should set token and return it via getToken', () => {
|
|
119
|
+
const client = new docPouchClient(mockServer.url);
|
|
120
|
+
client.setToken('my-test-token');
|
|
121
|
+
expect(client.getToken()).toBe('my-test-token');
|
|
122
|
+
expect(client.getAuthMethod()).toBe('jwt');
|
|
123
|
+
expect(client.isAuthenticated()).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should clear token when set to null', () => {
|
|
127
|
+
const client = new docPouchClient(mockServer.url);
|
|
128
|
+
client.setToken('my-test-token');
|
|
129
|
+
client.setToken(null);
|
|
130
|
+
expect(client.getToken()).toBeNull();
|
|
131
|
+
expect(client.getAuthMethod()).toBe('none');
|
|
132
|
+
expect(client.isAuthenticated()).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should be unauthenticated initially', () => {
|
|
136
|
+
const client = new docPouchClient(mockServer.url);
|
|
137
|
+
expect(client.isAuthenticated()).toBe(false);
|
|
138
|
+
expect(client.getAuthMethod()).toBe('none');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should be authenticated after login', async () => {
|
|
142
|
+
const client = new docPouchClient(mockServer.url);
|
|
143
|
+
await client.login(adminLogin);
|
|
144
|
+
expect(client.isAuthenticated()).toBe(true);
|
|
145
|
+
expect(client.getAuthMethod()).toBe('jwt');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should clear auth state on logout', async () => {
|
|
149
|
+
const client = new docPouchClient(mockServer.url);
|
|
150
|
+
await client.login(adminLogin);
|
|
151
|
+
await client.logout();
|
|
152
|
+
expect(client.isAuthenticated()).toBe(false);
|
|
153
|
+
expect(client.getAuthMethod()).toBe('none');
|
|
154
|
+
expect(client.getToken()).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ─── User Management ────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe('user management', () => {
|
|
161
|
+
it('should list all users as admin', async () => {
|
|
162
|
+
const client = new docPouchClient(mockServer.url);
|
|
163
|
+
await client.login(adminLogin);
|
|
164
|
+
const users: I_UserEntry[] = await client.listUsers();
|
|
165
|
+
expect(Array.isArray(users)).toBe(true);
|
|
166
|
+
expect(users.length).toBeGreaterThanOrEqual(2);
|
|
167
|
+
expect(users.some(u => u.name === 'admin')).toBe(true);
|
|
168
|
+
expect(users.some(u => u.name === 'testuser')).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should list users as non-admin', async () => {
|
|
172
|
+
const client = new docPouchClient(mockServer.url);
|
|
173
|
+
await client.login(userLogin);
|
|
174
|
+
const users: I_UserEntry[] = await client.listUsers();
|
|
175
|
+
expect(Array.isArray(users)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should create a new user as admin', async () => {
|
|
179
|
+
const client = new docPouchClient(mockServer.url);
|
|
180
|
+
await client.login(adminLogin);
|
|
181
|
+
const newUser = await client.createUser({
|
|
182
|
+
name: 'newuser',
|
|
183
|
+
password: 'newpass',
|
|
184
|
+
email: 'new@example.com',
|
|
185
|
+
department: 'sales',
|
|
186
|
+
group: 'beta',
|
|
187
|
+
isAdmin: false,
|
|
188
|
+
});
|
|
189
|
+
expect(newUser._id).toBeDefined();
|
|
190
|
+
expect(newUser.username).toBe('newuser');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should fail to create user as non-admin', async () => {
|
|
194
|
+
const client = new docPouchClient(mockServer.url);
|
|
195
|
+
await client.login(userLogin);
|
|
196
|
+
await expect(client.createUser({
|
|
197
|
+
name: 'shouldfail',
|
|
198
|
+
password: 'pass',
|
|
199
|
+
department: 'test',
|
|
200
|
+
group: 'test',
|
|
201
|
+
isAdmin: false,
|
|
202
|
+
})).rejects.toThrow('API error: 401');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should update a user as admin', async () => {
|
|
206
|
+
const client = new docPouchClient(mockServer.url);
|
|
207
|
+
await client.login(adminLogin);
|
|
208
|
+
const users: I_UserEntry[] = await client.listUsers();
|
|
209
|
+
const targetUser = users.find(u => u.name === 'testuser')!;
|
|
210
|
+
await client.updateUser(targetUser._id, { email: 'updated@example.com' });
|
|
211
|
+
const updatedUsers: I_UserEntry[] = await client.listUsers();
|
|
212
|
+
const updated = updatedUsers.find(u => u._id === targetUser._id);
|
|
213
|
+
expect(updated!.email).toBe('updated@example.com');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should remove a user as admin', async () => {
|
|
217
|
+
const client = new docPouchClient(mockServer.url);
|
|
218
|
+
await client.login(adminLogin);
|
|
219
|
+
const users: I_UserEntry[] = await client.listUsers();
|
|
220
|
+
const targetUser = users.find(u => u.name === 'testuser')!;
|
|
221
|
+
await client.removeUser(targetUser._id);
|
|
222
|
+
const usersAfter: I_UserEntry[] = await client.listUsers();
|
|
223
|
+
expect(usersAfter.find(u => u._id === targetUser._id)).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should fail to remove a user as non-admin', async () => {
|
|
227
|
+
const client = new docPouchClient(mockServer.url);
|
|
228
|
+
await client.login(userLogin);
|
|
229
|
+
await expect(client.removeUser('some-id')).rejects.toThrow('API error: 401');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ─── Document Management ────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
const sampleDoc = {
|
|
236
|
+
title: 'Test Doc',
|
|
237
|
+
description: 'A test document',
|
|
238
|
+
type: 1,
|
|
239
|
+
subType: 0,
|
|
240
|
+
content: [{ label: 'Content', importance: 0 }],
|
|
241
|
+
shareWithGroup: false,
|
|
242
|
+
shareWithDepartment: false,
|
|
243
|
+
public: false,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
describe('document management', () => {
|
|
247
|
+
it('should create a document', async () => {
|
|
248
|
+
const client = new docPouchClient(mockServer.url);
|
|
249
|
+
await client.login(adminLogin);
|
|
250
|
+
const doc = await client.createDocument(sampleDoc as I_DocumentEntry);
|
|
251
|
+
expect(doc._id).toBeDefined();
|
|
252
|
+
expect(doc.title).toBe('Test Doc');
|
|
253
|
+
expect(doc.owner).toBeDefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should list documents', async () => {
|
|
257
|
+
const client = new docPouchClient(mockServer.url);
|
|
258
|
+
await client.login(adminLogin);
|
|
259
|
+
const docs: I_DocumentEntry[] = await client.listDocuments();
|
|
260
|
+
expect(Array.isArray(docs)).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should list documents created by the user', async () => {
|
|
264
|
+
const client = new docPouchClient(mockServer.url);
|
|
265
|
+
await client.login(adminLogin);
|
|
266
|
+
await client.createDocument(sampleDoc as I_DocumentEntry);
|
|
267
|
+
const docs: I_DocumentEntry[] = await client.listDocuments();
|
|
268
|
+
expect(docs.length).toBe(1);
|
|
269
|
+
expect(docs[0].title).toBe('Test Doc');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should fetch documents by query', async () => {
|
|
273
|
+
const client = new docPouchClient(mockServer.url);
|
|
274
|
+
await client.login(adminLogin);
|
|
275
|
+
const created = await client.createDocument(sampleDoc as I_DocumentEntry);
|
|
276
|
+
const results: I_DocumentEntry[] = await client.fetchDocuments({ type: 1 } as I_DocumentQuery);
|
|
277
|
+
expect(results.length).toBe(1);
|
|
278
|
+
expect(results[0]._id).toBe(created._id);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should return empty array for non-matching query', async () => {
|
|
282
|
+
const client = new docPouchClient(mockServer.url);
|
|
283
|
+
await client.login(adminLogin);
|
|
284
|
+
const results: I_DocumentEntry[] = await client.fetchDocuments({ type: 999 } as I_DocumentQuery);
|
|
285
|
+
expect(results).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should update a document', async () => {
|
|
289
|
+
const client = new docPouchClient(mockServer.url);
|
|
290
|
+
await client.login(adminLogin);
|
|
291
|
+
const doc = await client.createDocument(sampleDoc as I_DocumentEntry);
|
|
292
|
+
await client.updateDocument(doc._id, { title: 'Updated' } as I_DocumentEntry);
|
|
293
|
+
const results: I_DocumentEntry[] = await client.fetchDocuments({ _id: doc._id } as I_DocumentQuery);
|
|
294
|
+
expect(results[0].title).toBe('Updated');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should remove a document', async () => {
|
|
298
|
+
const client = new docPouchClient(mockServer.url);
|
|
299
|
+
await client.login(adminLogin);
|
|
300
|
+
const doc = await client.createDocument(sampleDoc as I_DocumentEntry);
|
|
301
|
+
await client.removeDocument(doc._id);
|
|
302
|
+
const results: I_DocumentEntry[] = await client.fetchDocuments({ _id: doc._id } as I_DocumentQuery);
|
|
303
|
+
expect(results.length).toBe(0);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should allow non-owner to read public documents', async () => {
|
|
307
|
+
const adminClient = new docPouchClient(mockServer.url);
|
|
308
|
+
await adminClient.login(adminLogin);
|
|
309
|
+
const doc = await adminClient.createDocument({
|
|
310
|
+
...sampleDoc, public: true, title: 'Public Doc',
|
|
311
|
+
} as I_DocumentEntry);
|
|
312
|
+
|
|
313
|
+
const userClient = new docPouchClient(mockServer.url);
|
|
314
|
+
await userClient.login(userLogin);
|
|
315
|
+
const docs: I_DocumentEntry[] = await userClient.listDocuments();
|
|
316
|
+
expect(docs.some(d => d._id === doc._id)).toBe(true);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should hide non-public documents from other users', async () => {
|
|
320
|
+
const adminClient = new docPouchClient(mockServer.url);
|
|
321
|
+
await adminClient.login(adminLogin);
|
|
322
|
+
await adminClient.createDocument({
|
|
323
|
+
...sampleDoc, public: false, title: 'Private Doc',
|
|
324
|
+
} as I_DocumentEntry);
|
|
325
|
+
|
|
326
|
+
const userClient = new docPouchClient(mockServer.url);
|
|
327
|
+
await userClient.login(userLogin);
|
|
328
|
+
const docs: I_DocumentEntry[] = await userClient.listDocuments();
|
|
329
|
+
expect(docs.some(d => d.title === 'Private Doc')).toBe(false);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ─── Data Structure Management ──────────────────────────────────
|
|
334
|
+
|
|
335
|
+
const sampleFields = [
|
|
336
|
+
{ name: 'field1', displayName: 'Field 1', type: 'string' },
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
describe('data structure management', () => {
|
|
340
|
+
it('should create a structure as admin', async () => {
|
|
341
|
+
const client = new docPouchClient(mockServer.url);
|
|
342
|
+
await client.login(adminLogin);
|
|
343
|
+
const structure = await client.createStructure({
|
|
344
|
+
name: 'Test Structure',
|
|
345
|
+
description: 'A test',
|
|
346
|
+
fields: sampleFields,
|
|
347
|
+
});
|
|
348
|
+
expect(structure._id).toBeDefined();
|
|
349
|
+
expect(structure.name).toBe('Test Structure');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should fail to create a structure as non-admin', async () => {
|
|
353
|
+
const client = new docPouchClient(mockServer.url);
|
|
354
|
+
await client.login(userLogin);
|
|
355
|
+
await expect(client.createStructure({
|
|
356
|
+
name: 'Should Fail',
|
|
357
|
+
description: '',
|
|
358
|
+
fields: sampleFields,
|
|
359
|
+
})).rejects.toThrow('API error: 401');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should list all structures', async () => {
|
|
363
|
+
const client = new docPouchClient(mockServer.url);
|
|
364
|
+
await client.login(adminLogin);
|
|
365
|
+
await client.createStructure({ name: 'S1', description: '', fields: sampleFields });
|
|
366
|
+
await client.createStructure({ name: 'S2', description: '', fields: sampleFields });
|
|
367
|
+
const list = await client.getStructures();
|
|
368
|
+
expect(list.length).toBe(2);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should update a structure', async () => {
|
|
372
|
+
const client = new docPouchClient(mockServer.url);
|
|
373
|
+
await client.login(adminLogin);
|
|
374
|
+
const s = await client.createStructure({ name: 'Original', description: '', fields: sampleFields });
|
|
375
|
+
await client.updateStructure(s._id!, { name: 'Renamed' } as I_DataStructure);
|
|
376
|
+
const list = await client.getStructures();
|
|
377
|
+
const updated = list.find(x => x._id === s._id);
|
|
378
|
+
expect(updated!.name).toBe('Renamed');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should remove a structure', async () => {
|
|
382
|
+
const client = new docPouchClient(mockServer.url);
|
|
383
|
+
await client.login(adminLogin);
|
|
384
|
+
const s = await client.createStructure({ name: 'To Delete', description: '', fields: sampleFields });
|
|
385
|
+
await client.removeStructure(s._id!);
|
|
386
|
+
const list = await client.getStructures();
|
|
387
|
+
expect(list.find(x => x._id === s._id)).toBeUndefined();
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ─── Version Check ──────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
describe('version check', () => {
|
|
394
|
+
it('should be requestable without auth', async () => {
|
|
395
|
+
const client = new docPouchClient(mockServer.url);
|
|
396
|
+
const version = client.getVersion();
|
|
397
|
+
expect(version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should return a valid semver string', () => {
|
|
401
|
+
const client = new docPouchClient(mockServer.url);
|
|
402
|
+
const parts = client.getVersion().split('.');
|
|
403
|
+
expect(parts.length).toBe(3);
|
|
404
|
+
parts.forEach(p => expect(/^\d+$/.test(p)).toBe(true));
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ─── Real-Time Sync (WebSocket) ─────────────────────────────────
|
|
409
|
+
|
|
410
|
+
describe('real-time sync', () => {
|
|
411
|
+
it('should connect the socket after login and enabling sync', async () => {
|
|
412
|
+
const client = new docPouchClient(mockServer.url);
|
|
413
|
+
await client.login(adminLogin);
|
|
414
|
+
client.setRealTimeSync(true);
|
|
415
|
+
await waitForSocket(client);
|
|
416
|
+
expect(client.socket.connected).toBe(true);
|
|
417
|
+
expect(client.socket.id).toBeDefined();
|
|
418
|
+
client.socket.disconnect();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('should disconnect when disabling sync', async () => {
|
|
422
|
+
const client = new docPouchClient(mockServer.url);
|
|
423
|
+
await client.login(adminLogin);
|
|
424
|
+
client.setRealTimeSync(true);
|
|
425
|
+
await waitForSocket(client);
|
|
426
|
+
client.setRealTimeSync(false);
|
|
427
|
+
await delay(500);
|
|
428
|
+
expect(client.socket.connected).toBe(false);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('should respond to heartbeat ping with pong', async () => {
|
|
432
|
+
const client = new docPouchClient(mockServer.url);
|
|
433
|
+
await client.login(adminLogin);
|
|
434
|
+
client.setRealTimeSync(true);
|
|
435
|
+
await waitForSocket(client);
|
|
436
|
+
|
|
437
|
+
mockServer.io.emit('heartbeatPing', { timestamp: Date.now() });
|
|
438
|
+
await mockServer.waitForPong(4000);
|
|
439
|
+
|
|
440
|
+
client.socket.disconnect();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('should forward application events to the callback', async () => {
|
|
444
|
+
const callback = jest.fn();
|
|
445
|
+
const client = new docPouchClient(mockServer.url, 80, callback);
|
|
446
|
+
await client.login(adminLogin);
|
|
447
|
+
client.setRealTimeSync(true);
|
|
448
|
+
await waitForSocket(client);
|
|
449
|
+
|
|
450
|
+
mockServer.io.emit('newDocument', { _id: 'test123', title: 'WS Doc' });
|
|
451
|
+
await delay(300);
|
|
452
|
+
expect(callback).toHaveBeenCalledWith('newDocument', expect.objectContaining({ _id: 'test123' }));
|
|
453
|
+
|
|
454
|
+
client.socket.disconnect();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('should not skip socket init if setting unchanged', async () => {
|
|
458
|
+
const client = new docPouchClient(mockServer.url);
|
|
459
|
+
expect(client.realTimeSync).toBe(false);
|
|
460
|
+
client.setRealTimeSync(false);
|
|
461
|
+
expect(client.realTimeSync).toBe(false);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should connect socket when token is set after enabling sync', async () => {
|
|
465
|
+
const client = new docPouchClient(mockServer.url);
|
|
466
|
+
client.setRealTimeSync(true);
|
|
467
|
+
expect(client.socket.connected).toBe(false);
|
|
468
|
+
await client.login(adminLogin);
|
|
469
|
+
await waitForSocket(client);
|
|
470
|
+
expect(client.socket.connected).toBe(true);
|
|
471
|
+
client.socket.disconnect();
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ─── OIDC Authentication ────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
describe('OIDC authentication', () => {
|
|
478
|
+
beforeEach(() => {
|
|
479
|
+
const w = (globalThis as any).window;
|
|
480
|
+
w.location.href = '';
|
|
481
|
+
w.location.search = '';
|
|
482
|
+
w.location.pathname = '';
|
|
483
|
+
jest.clearAllMocks();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should redirect to the OIDC authorization endpoint on loginWithOidc', async () => {
|
|
487
|
+
const client = new docPouchClient(mockServer.url);
|
|
488
|
+
await client.loginWithOidc({
|
|
489
|
+
issuer: mockServer.url,
|
|
490
|
+
clientId: 'test-client',
|
|
491
|
+
redirectUri: `${mockServer.url}/mock-oidc/callback`,
|
|
492
|
+
scopes: ['openid', 'profile'],
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const w = (globalThis as any).window;
|
|
496
|
+
expect(w.location.href).toContain('/mock-oidc/authorize');
|
|
497
|
+
expect(w.location.href).toContain('response_type=code');
|
|
498
|
+
expect(w.location.href).toContain('client_id=test-client');
|
|
499
|
+
expect(w.location.href).toContain('code_challenge_method=S256');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should exchange code for tokens in handleOidcCallback', async () => {
|
|
503
|
+
const client = new docPouchClient(mockServer.url);
|
|
504
|
+
await client.loginWithOidc({
|
|
505
|
+
issuer: mockServer.url,
|
|
506
|
+
clientId: 'test-client',
|
|
507
|
+
redirectUri: `${mockServer.url}/mock-oidc/callback`,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const authUrl = (globalThis as any).window.location.href;
|
|
511
|
+
const authResponse = await fetch(authUrl);
|
|
512
|
+
const callbackUrl = authResponse.url;
|
|
513
|
+
const callbackParams = new URL(callbackUrl).searchParams;
|
|
514
|
+
const code = callbackParams.get('code');
|
|
515
|
+
const state = callbackParams.get('state');
|
|
516
|
+
|
|
517
|
+
(globalThis as any).window.location.search = `?code=${code}&state=${state}`;
|
|
518
|
+
|
|
519
|
+
const result = await client.handleOidcCallback();
|
|
520
|
+
expect(result).toBe(true);
|
|
521
|
+
expect(client.getAuthMethod()).toBe('oidc');
|
|
522
|
+
expect(client.isAuthenticated()).toBe(true);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('should return false when no code or state in URL', async () => {
|
|
526
|
+
const client = new docPouchClient(mockServer.url);
|
|
527
|
+
(globalThis as any).window.location.search = '';
|
|
528
|
+
const result = await client.handleOidcCallback();
|
|
529
|
+
expect(result).toBe(false);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('should throw on state mismatch', async () => {
|
|
533
|
+
const client = new docPouchClient(mockServer.url);
|
|
534
|
+
await client.loginWithOidc({
|
|
535
|
+
issuer: mockServer.url,
|
|
536
|
+
clientId: 'test-client',
|
|
537
|
+
redirectUri: `${mockServer.url}/mock-oidc/callback`,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
(globalThis as any).window.location.search = '?code=fake&state=wrong-state';
|
|
541
|
+
await expect(client.handleOidcCallback()).rejects.toThrow('State mismatch');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('should throw on OAuth error', async () => {
|
|
545
|
+
const client = new docPouchClient(mockServer.url);
|
|
546
|
+
(globalThis as any).window.location.search = '?error=access_denied';
|
|
547
|
+
await expect(client.handleOidcCallback()).rejects.toThrow('OAuth error');
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// ─── OIDC Dynamic Client Registration ───────────────────────────
|
|
552
|
+
|
|
553
|
+
describe('OIDC dynamic client registration', () => {
|
|
554
|
+
const clientMetadata = {
|
|
555
|
+
client_name: 'My Test Client',
|
|
556
|
+
redirect_uris: ['http://localhost:8080/cb'],
|
|
557
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
558
|
+
response_types: ['code'],
|
|
559
|
+
scope: 'openid profile',
|
|
560
|
+
token_endpoint_auth_method: 'client_secret_basic' as const,
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
it('should register a new OIDC client', async () => {
|
|
564
|
+
const client = new docPouchClient(mockServer.url);
|
|
565
|
+
const result = await client.registerOidcClient(clientMetadata, mockServer.registrationToken);
|
|
566
|
+
expect(result.client_id).toBeDefined();
|
|
567
|
+
expect(result.client_secret).toBeDefined();
|
|
568
|
+
expect(result.client_name).toBe('My Test Client');
|
|
569
|
+
expect(result.redirect_uris).toEqual(['http://localhost:8080/cb']);
|
|
570
|
+
expect(result.registration_access_token).toBeDefined();
|
|
571
|
+
expect(result.registration_client_uri).toBeDefined();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('should fail to register without a registration token', async () => {
|
|
575
|
+
const client = new docPouchClient(mockServer.url);
|
|
576
|
+
await expect(client.registerOidcClient(clientMetadata)).rejects.toThrow('API error: 401');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('should retrieve a registered OIDC client', async () => {
|
|
580
|
+
const client = new docPouchClient(mockServer.url);
|
|
581
|
+
const registered = await client.registerOidcClient(clientMetadata, mockServer.registrationToken);
|
|
582
|
+
const result = await client.getOidcClient(registered.client_id, mockServer.registrationToken);
|
|
583
|
+
expect(result.client_id).toBe(registered.client_id);
|
|
584
|
+
expect(result.client_name).toBe('My Test Client');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('should fail to retrieve a non-existent client', async () => {
|
|
588
|
+
const client = new docPouchClient(mockServer.url);
|
|
589
|
+
await expect(client.getOidcClient('non-existent-id', mockServer.registrationToken)).rejects.toThrow('API error: 404');
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('should update a registered OIDC client', async () => {
|
|
593
|
+
const client = new docPouchClient(mockServer.url);
|
|
594
|
+
const registered = await client.registerOidcClient(clientMetadata, mockServer.registrationToken);
|
|
595
|
+
const updated = await client.updateOidcClient(registered.client_id, {
|
|
596
|
+
client_name: 'Updated Client',
|
|
597
|
+
redirect_uris: ['http://localhost:9090/cb'],
|
|
598
|
+
}, mockServer.registrationToken);
|
|
599
|
+
expect(updated.client_name).toBe('Updated Client');
|
|
600
|
+
expect(updated.redirect_uris).toEqual(['http://localhost:9090/cb']);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('should delete a registered OIDC client', async () => {
|
|
604
|
+
const client = new docPouchClient(mockServer.url);
|
|
605
|
+
const registered = await client.registerOidcClient(clientMetadata, mockServer.registrationToken);
|
|
606
|
+
await client.deleteOidcClient(registered.client_id, mockServer.registrationToken);
|
|
607
|
+
await expect(client.getOidcClient(registered.client_id, mockServer.registrationToken)).rejects.toThrow('API error: 404');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('should fail to delete a non-existent client', async () => {
|
|
611
|
+
const client = new docPouchClient(mockServer.url);
|
|
612
|
+
await expect(client.deleteOidcClient('non-existent-id', mockServer.registrationToken)).rejects.toThrow('API error: 404');
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// ─── Error Handling ─────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
describe('error handling', () => {
|
|
619
|
+
it('should throw on 404 for invalid user ID', async () => {
|
|
620
|
+
const client = new docPouchClient(mockServer.url);
|
|
621
|
+
await client.login(adminLogin);
|
|
622
|
+
await expect(client.removeUser('nonexistent-id')).rejects.toThrow('API error: 404');
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('should throw on 404 for invalid document ID', async () => {
|
|
626
|
+
const client = new docPouchClient(mockServer.url);
|
|
627
|
+
await client.login(adminLogin);
|
|
628
|
+
await expect(client.removeDocument('nonexistent-id')).rejects.toThrow('API error: 404');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('should throw on 404 for invalid structure ID', async () => {
|
|
632
|
+
const client = new docPouchClient(mockServer.url);
|
|
633
|
+
await client.login(adminLogin);
|
|
634
|
+
await expect(client.removeStructure('nonexistent-id')).rejects.toThrow('API error: 404');
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('should throw when not authenticated on protected route', async () => {
|
|
638
|
+
const client = new docPouchClient(mockServer.url);
|
|
639
|
+
await expect(client.listUsers()).rejects.toThrow('API error: 401');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('should clear token on 401', async () => {
|
|
643
|
+
const client = new docPouchClient(mockServer.url);
|
|
644
|
+
client.setToken('invalid-token');
|
|
645
|
+
await expect(client.listUsers()).rejects.toThrow('API error: 401');
|
|
646
|
+
expect(client.getToken()).toBeNull();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('should throw on network error for unreachable server', async () => {
|
|
650
|
+
const client = new docPouchClient('http://localhost:1');
|
|
651
|
+
client.setToken('some-token');
|
|
652
|
+
await expect(client.listUsers()).rejects.toThrow();
|
|
653
|
+
});
|
|
654
|
+
});
|