pryv 3.4.1 → 3.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pryv",
3
- "version": "3.4.1",
3
+ "version": "3.6.0",
4
4
  "description": "Pryv JavaScript library",
5
5
  "keywords": [
6
6
  "Pryv",
@@ -22,8 +22,15 @@ class AuthController {
22
22
  validateSettings.call(this, settings);
23
23
 
24
24
  this.stateChangeListeners = [];
25
+ // External `onStateChange` callers only see `{ status, id, key, serviceInfo? }`
26
+ // on AUTHORIZED — credentials (`username`, `token`, `apiEndpoint`) stay
27
+ // inside the lib. Internal listeners (e.g. LoginButton, for cookie
28
+ // autologin) get the full unfiltered state.
25
29
  if (this.settings.onStateChange) {
26
- this.stateChangeListeners.push(this.settings.onStateChange);
30
+ const externalListener = this.settings.onStateChange;
31
+ this.stateChangeListeners.push(function (state) {
32
+ externalListener(filterForExternalListener(state));
33
+ });
27
34
  }
28
35
  this.service = service;
29
36
 
@@ -166,6 +173,10 @@ class AuthController {
166
173
  async startAuthRequest () {
167
174
  // @ts-ignore - postAccess uses .call(this) for context
168
175
  this.state = await postAccess.call(this);
176
+ // Remember the polling key so listeners on the terminal AUTHORIZED
177
+ // state can be handed `{ key, serviceInfo? }` (the polling response
178
+ // itself doesn't echo `key` back).
179
+ this._authFlowKey = this.state?.key;
169
180
 
170
181
  await doPolling.call(this);
171
182
 
@@ -205,6 +216,11 @@ class AuthController {
205
216
  // @ts-ignore - this is bound via .call()
206
217
  setTimeout(await doPolling.bind(this), this.state?.poll_rate_ms);
207
218
  } else {
219
+ // Carry the key forward — listeners on the narrow public surface
220
+ // need it, and the server doesn't echo it back on ACCEPTED.
221
+ if (this._authFlowKey != null && pollResponse.key == null) {
222
+ pollResponse.key = this._authFlowKey;
223
+ }
208
224
  this.state = pollResponse;
209
225
  }
210
226
 
@@ -245,6 +261,38 @@ class AuthController {
245
261
 
246
262
  // ----------- private methods -------------
247
263
 
264
+ /**
265
+ * Narrow the state passed to *external* `onStateChange` callers so the
266
+ * calling app sees only `{ status, id, key, serviceInfo? }` on the
267
+ * terminal AUTHORIZED state reached through the auth-flow polling path.
268
+ * `username` / `token` / `apiEndpoint` are kept inside the lib; the
269
+ * calling app uses `pryv.connectFromKey(key, serviceInfoUrl)` to obtain
270
+ * a `Connection`.
271
+ *
272
+ * The cookie-autologin path (no fresh `key` available, restored from
273
+ * `LoginButton.getAuthorizationData()`) passes through unchanged so
274
+ * existing pages that build a `Connection` directly from the restored
275
+ * state on page load keep working.
276
+ *
277
+ * Non-AUTHORIZED states pass through unchanged so error messages /
278
+ * loading flags / etc. still reach the listener.
279
+ *
280
+ * @param {Object} state - full internal state
281
+ * @returns {Object} narrowed state
282
+ */
283
+ function filterForExternalListener (state) {
284
+ if (state == null || state.status !== AuthStates.AUTHORIZED) {
285
+ return state;
286
+ }
287
+ // No key → cookie-autologin path; preserve existing shape.
288
+ if (state.key == null) {
289
+ return state;
290
+ }
291
+ const out = { status: state.status, id: state.id, key: state.key };
292
+ if (state.serviceInfo != null) out.serviceInfo = state.serviceInfo;
293
+ return out;
294
+ }
295
+
248
296
  async function checkAutoLogin (authController) {
249
297
  const loginButton = authController.loginButton;
250
298
  if (loginButton == null) {
package/src/Connection.js CHANGED
@@ -8,6 +8,7 @@ const libGetEventStreamed = require('./lib/getEventStreamed');
8
8
  const PryvError = require('./lib/PryvError');
9
9
  const StaleAccessIdError = require('./lib/StaleAccessIdError');
10
10
  const buildSearchParams = require('./lib/buildSearchParams');
11
+ const resolveDotPath = require('./lib/resolveDotPath');
11
12
 
12
13
  /**
13
14
  * @class Connection
@@ -77,8 +78,8 @@ class Connection {
77
78
  * caches the result; subsequent calls return the cached copy in O(1).
78
79
  * Pass `forceRefresh: true` to invalidate the cache and fetch a fresh
79
80
  * copy from the server — used internally by `connection.socket` to
80
- * react to Plan 66 `accessUpdated` server-push events. A failed
81
- * server fetch leaves any prior cached value intact.
81
+ * react to `accessUpdated` server-push events. A failed server
82
+ * fetch leaves any prior cached value intact.
82
83
  *
83
84
  * @param {boolean} [forceRefresh=false] - bypass + refresh the cache
84
85
  * @returns {Promise<AccessInfo>} Promise resolving to the access info
@@ -350,6 +351,44 @@ class Connection {
350
351
  return res.body;
351
352
  }
352
353
 
354
+ /**
355
+ * Get the latest event per value for a content path — typical form-prefill
356
+ * lookup ("latest assertion per code"). Queries `events.get` with a
357
+ * `content` condition `{ path, in: values }` (server must support content
358
+ * queries — see `Service.supportsContentQueries()`), pages through the
359
+ * time-descending result and keeps the first (= latest) event per value.
360
+ * Handles paging internally, so the result is correct regardless of the
361
+ * default `events.get` page size.
362
+ * @param {string} path - dot-path into `content` (or `$` for the root value)
363
+ * @param {Array<string|number|boolean>} values - values to look up (one Map entry max per value)
364
+ * @param {Object} [baseQuery] - additional `events.get` params (e.g. `streams`, `types`, `fromTime`); passed through
365
+ * @returns {Promise<Map<string|number|boolean, Object>>} value → latest matching event; values with no match are absent
366
+ */
367
+ async getLatestByContent (path, values, baseQuery = {}) {
368
+ const PAGE_LIMIT = 1000;
369
+ const lookup = new Set(values);
370
+ const found = new Map();
371
+ const condition = { path, in: [...lookup] };
372
+ const content = (baseQuery.content || []).concat([condition]);
373
+ let skip = 0;
374
+ while (found.size < lookup.size) {
375
+ const params = Object.assign({}, baseQuery, {
376
+ content,
377
+ sortAscending: false,
378
+ skip,
379
+ limit: PAGE_LIMIT
380
+ });
381
+ const events = await this.apiOne('events.get', params, 'events');
382
+ for (const event of events) {
383
+ const value = resolveDotPath(event.content, path);
384
+ if (lookup.has(value) && !found.has(value)) found.set(value, event);
385
+ }
386
+ if (events.length < PAGE_LIMIT) break;
387
+ skip += events.length;
388
+ }
389
+ return found;
390
+ }
391
+
353
392
  /**
354
393
  * Create an event with attached file
355
394
  * NODE.jS ONLY
@@ -429,10 +468,10 @@ class Connection {
429
468
  }
430
469
 
431
470
  /**
432
- * Plan 66 (open-pryv.io ≥ 2.0.0-pre.X): update an access by composite id.
433
- * Wraps `accesses.update` and translates the 409 `stale-resource` response
434
- * into a typed `StaleAccessIdError` so callers can `instanceof`-test and
435
- * refetch + retry without re-parsing the inner error.
471
+ * Update an access by composite id (Pryv.io ≥ 2.0.0-pre.X). Wraps
472
+ * `accesses.update` and translates the 409 `stale-resource` response
473
+ * into a typed `StaleAccessIdError` so callers can `instanceof`-test
474
+ * and refetch + retry without re-parsing the inner error.
436
475
  *
437
476
  * Pass `id` as the wire-format reference returned by the server — bare
438
477
  * cuid on a never-updated access, composite `<base>:<serial>` otherwise.
@@ -456,8 +495,8 @@ class Connection {
456
495
  }
457
496
 
458
497
  /**
459
- * Plan 66: fetch an access by composite id including its full version
460
- * history (oldest first). Server: `accesses.getOne ?includeHistory=true`.
498
+ * Fetch an access by composite id including its full version history
499
+ * (oldest first). Server: `accesses.getOne ?includeHistory=true`.
461
500
  *
462
501
  * Useful for audit views. Pass the composite `<base>:<serial>` to
463
502
  * inspect a specific past version (the result's `current` field then
package/src/Service.js CHANGED
@@ -80,6 +80,17 @@ class Service {
80
80
  return (infos.features == null || infos.features.noHF !== true);
81
81
  }
82
82
 
83
+ /**
84
+ * Whether the platform supports `events.get` content/clientData query
85
+ * conditions (`features.contentQueries` in service info). Older platforms
86
+ * reject the parameters with a 400 — use this to pick a fallback path.
87
+ * @returns {Promise<boolean>}
88
+ */
89
+ async supportsContentQueries () {
90
+ const infos = await this.info();
91
+ return infos.features != null && infos.features.contentQueries === true;
92
+ }
93
+
83
94
  /**
84
95
  * Check if a service has username in the hostname or in the path of the API.
85
96
  * @returns {Promise<boolean>} Promise resolving to true if the service does not rely on DNS to find a host related to a username
@@ -521,6 +532,40 @@ class Service {
521
532
  return body;
522
533
  }
523
534
 
535
+ /**
536
+ * Resolve a completed auth-flow polling key into a `Connection`.
537
+ *
538
+ * Pairs with `Service.startAccessRequest` / `Service.pollAccessRequest`
539
+ * and the headless polling pattern: the calling app holds only the
540
+ * `key` returned by the auth-flow (not the underlying token /
541
+ * apiEndpoint), and uses this method to build a working `Connection`.
542
+ *
543
+ * The implementation polls `<access>/<key>` once; the call MUST be
544
+ * made while the access is still in the ACCEPTED state (which
545
+ * persists until expiry — see `expireAfter` on the access request).
546
+ *
547
+ * @param {string} key - polling key from `startAccessRequest`
548
+ * @returns {Promise<Connection>}
549
+ * @throws {PryvError} if the key is not ACCEPTED (NEED_SIGNIN, REFUSED, ERROR)
550
+ */
551
+ async connectFromKey (key) {
552
+ if (!key) {
553
+ throw new PryvError('connectFromKey requires a key');
554
+ }
555
+ const body = await this.pollAccessRequest(key);
556
+ if (body.status !== 'ACCEPTED') {
557
+ throw new PryvError(
558
+ 'connectFromKey: access is not ACCEPTED (status=' + body.status + ')'
559
+ );
560
+ }
561
+ if (!body.apiEndpoint) {
562
+ throw new PryvError(
563
+ 'connectFromKey: ACCEPTED response missing apiEndpoint'
564
+ );
565
+ }
566
+ return new Connection(body.apiEndpoint, this);
567
+ }
568
+
524
569
  /**
525
570
  * Set a new password using a reset token (from the reset email).
526
571
  * Pre-auth — no login token required.
@@ -588,4 +633,5 @@ const Connection = require('./Connection');
588
633
  * @property {string} [assets.definitions] URL to json object with assets definitions
589
634
  * @property {Object} [features] Platform feature flags
590
635
  * @property {boolean} [features.noHF] True if HF data is not supported
636
+ * @property {boolean} [features.contentQueries] True if events.get content/clientData query conditions are supported
591
637
  */
package/src/index.d.ts CHANGED
@@ -267,6 +267,22 @@ declare module 'pryv' {
267
267
  not?: Identifier[];
268
268
  };
269
269
 
270
+ /**
271
+ * Condition on a JSON path of events' `content` or `clientData`.
272
+ * `path` is a dot-path (segments: [a-zA-Z0-9_:-]) or `$` for the root
273
+ * value; exactly one operator per condition. Conditions AND together.
274
+ * Server support is advertised by `features.contentQueries` (see
275
+ * `Service.supportsContentQueries()`).
276
+ */
277
+ export type ContentQueryCondition = { path: string } & (
278
+ | { eq: string | number | boolean }
279
+ | { neq: string | number | boolean }
280
+ | { in: Array<string | number | boolean> }
281
+ | { exists: boolean }
282
+ | { gt: number } | { gte: number } | { lt: number } | { lte: number }
283
+ | { prefix: string }
284
+ );
285
+
270
286
  export type EventQueryParams = {
271
287
  fromTime: Timestamp;
272
288
  toTime: Timestamp;
@@ -280,6 +296,8 @@ declare module 'pryv' {
280
296
  state: 'default' | 'trashed' | 'all';
281
297
  modifiedSince: Timestamp;
282
298
  includeDeletion: boolean;
299
+ content: ContentQueryCondition[];
300
+ clientData: ContentQueryCondition[];
283
301
  };
284
302
 
285
303
  export type EventQueryParamsStreamQuery = Omit<EventQueryParams, 'streams'> & {
@@ -705,6 +723,15 @@ declare module 'pryv' {
705
723
  queryParams: Partial<EventQueryParamsStreamQuery>,
706
724
  forEachEvent: StreamedEventsHandler,
707
725
  ): Promise<StreamedEventsResult>;
726
+ /**
727
+ * Latest event per value for a content path (form-prefill lookup).
728
+ * Pages internally; requires server content-query support.
729
+ */
730
+ getLatestByContent(
731
+ path: string,
732
+ values: Array<string | number | boolean>,
733
+ baseQuery?: Partial<EventQueryParamsStreamQuery>,
734
+ ): Promise<Map<string | number | boolean, Event>>;
708
735
  createEventWithFile(
709
736
  params: EventFileCreationParams,
710
737
  filePath: string | Buffer | Blob,
@@ -725,9 +752,9 @@ declare module 'pryv' {
725
752
  ): Promise<HFSeriesAddResult>;
726
753
  /** Memoized; pass `forceRefresh: true` to bypass + refresh the cache. */
727
754
  accessInfo(forceRefresh?: boolean): Promise<AccessInfo>;
728
- /** Plan 66: update an access by composite id. */
755
+ /** Update an access by composite id. */
729
756
  updateAccess(id: string, changes: object): Promise<object>;
730
- /** Plan 66: fetch an access including its full version history. */
757
+ /** Fetch an access including its full version history. */
731
758
  getAccessWithHistory(id: string): Promise<{ access: object, current?: string, history?: object[] }>;
732
759
  revoke(throwOnFail?: boolean, usingConnection?: Connection): Promise<any>;
733
760
  readonly deltaTime: number;
@@ -826,6 +853,8 @@ declare module 'pryv' {
826
853
  ): Promise<Connection>;
827
854
 
828
855
  supportsHF(): Promise<boolean>;
856
+ /** Whether the platform supports events.get content/clientData query conditions. */
857
+ supportsContentQueries(): Promise<boolean>;
829
858
  isDnsLess(): Promise<boolean>;
830
859
 
831
860
  userExists(userId: string): Promise<boolean>;
package/src/index.js CHANGED
@@ -11,11 +11,13 @@
11
11
  * @property {pryv.utils} utils - Exposes some utils for HTTP calls and tools to manipulate Pryv's API endpoints
12
12
  * @property {pryv.PryvError} PryvError - Custom error class with innerObject + structured API-error fields
13
13
  * @property {pryv.MfaRequiredError} MfaRequiredError - Thrown by Service.login when the platform returns an mfaToken instead of a token. Carries `.mfaToken`.
14
- * @property {pryv.StaleAccessIdError} StaleAccessIdError - Plan 66: thrown when a Pryv.io server rejects an `accesses.update` / `accesses.delete` with a 409 stale-resource. Refetch + retry.
14
+ * @property {pryv.StaleAccessIdError} StaleAccessIdError - Thrown when a Pryv.io server rejects an `accesses.update` / `accesses.delete` with a 409 stale-resource. Refetch + retry.
15
15
  * @property {Object} ERRORS - Catalogue of Pryv API error ids (mirrors open-pryv.io/components/errors)
16
16
  */
17
+ const Service = require('./Service');
18
+
17
19
  module.exports = {
18
- Service: require('./Service'),
20
+ Service,
19
21
  Connection: require('./Connection'),
20
22
  Auth: require('./Auth'),
21
23
  Browser: require('./Browser'),
@@ -24,5 +26,25 @@ module.exports = {
24
26
  MfaRequiredError: require('./lib/MfaRequiredError'),
25
27
  StaleAccessIdError: require('./lib/StaleAccessIdError'),
26
28
  ERRORS: require('./lib/errorIds'),
27
- version: require('../package.json').version
29
+ version: require('../package.json').version,
30
+ connectFromKey
28
31
  };
32
+
33
+ /**
34
+ * Module-level convenience over `Service#connectFromKey` — builds a
35
+ * `Service` on the fly, fetches its info, and resolves the given
36
+ * auth-flow polling key into a working `Connection`.
37
+ *
38
+ * Mirrors the `pryv.connectFromKey(key, serviceInfoUrl)` shape the
39
+ * headless polling pattern documents.
40
+ *
41
+ * @param {string} key - polling key from `Service.startAccessRequest`
42
+ * @param {string} serviceInfoUrl - URL of the platform's `/service/info`
43
+ * @param {Object} [serviceCustomizations] - same shape as `new Service(url, customizations)`
44
+ * @returns {Promise<import('./Connection')>}
45
+ */
46
+ async function connectFromKey (key, serviceInfoUrl, serviceCustomizations) {
47
+ const service = new Service(serviceInfoUrl, serviceCustomizations);
48
+ await service.info();
49
+ return service.connectFromKey(key);
50
+ }
@@ -6,9 +6,9 @@
6
6
  const PryvError = require('./PryvError');
7
7
 
8
8
  /**
9
- * Plan 66: typed error surfaced when a Pryv.io server (≥ 2.0.0-pre.X)
10
- * rejects an `accesses.update` or `accesses.delete` call with a
11
- * 409 `stale-resource` response.
9
+ * Typed error surfaced when a Pryv.io server (≥ 2.0.0-pre.X) rejects
10
+ * an `accesses.update` or `accesses.delete` call with a 409
11
+ * `stale-resource` response.
12
12
  *
13
13
  * The composite access id `<base>:<serial>` carries the version the
14
14
  * caller last observed. If the access has since been updated, the
@@ -4,8 +4,12 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Build URL search params string from an object, properly handling arrays.
8
- * Arrays are expanded as repeated keys: { a: ['x', 'y'] } => 'a=x&a=y'
7
+ * Build URL search params string from an object, properly handling arrays
8
+ * and structured values.
9
+ * - Arrays of scalars are expanded as repeated keys: { a: ['x', 'y'] } => 'a=x&a=y'
10
+ * - Arrays containing objects (e.g. `content` / `clientData` conditions,
11
+ * rich `streams` queries) and plain objects are sent as one
12
+ * JSON-encoded parameter, which the API parses back.
9
13
  * @param {Object} params - Query parameters object
10
14
  * @returns {string} - URL encoded query string
11
15
  */
@@ -13,9 +17,15 @@ function buildSearchParams (params) {
13
17
  const searchParams = new URLSearchParams();
14
18
  for (const [key, value] of Object.entries(params)) {
15
19
  if (Array.isArray(value)) {
16
- for (const item of value) {
17
- searchParams.append(key, item);
20
+ if (value.some((item) => item !== null && typeof item === 'object')) {
21
+ searchParams.append(key, JSON.stringify(value));
22
+ } else {
23
+ for (const item of value) {
24
+ searchParams.append(key, item);
25
+ }
18
26
  }
27
+ } else if (value !== null && typeof value === 'object') {
28
+ searchParams.append(key, JSON.stringify(value));
19
29
  } else if (value !== undefined && value !== null) {
20
30
  searchParams.append(key, value);
21
31
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+
6
+ /**
7
+ * Resolve a content-query dot-path against a JSON value.
8
+ * `$` addresses the root value itself. Returns `undefined` when the path
9
+ * does not lead to a value.
10
+ * @param {*} root - The JSON value (typically an event's `content`)
11
+ * @param {string} path - Dot-path (e.g. 'drug.codes.atc') or '$'
12
+ * @returns {*} The value at the path, or undefined
13
+ */
14
+ function resolveDotPath (root, path) {
15
+ if (path === '$') return root;
16
+ let current = root;
17
+ for (const segment of path.split('.')) {
18
+ if (current == null || typeof current !== 'object' || Array.isArray(current)) return undefined;
19
+ if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined;
20
+ current = current[segment];
21
+ }
22
+ return current;
23
+ }
24
+
25
+ module.exports = resolveDotPath;
package/src/utils.js CHANGED
@@ -226,11 +226,11 @@ const utils = module.exports = {
226
226
  },
227
227
 
228
228
  /**
229
- * Plan 66 (open-pryv.io 2.0.0-pre.X): parse a wire-format access
230
- * reference into `{ base, serial }`. Accepts both bare cuid
231
- * (`"abc123"` → `{ base: 'abc123', serial: null }`) and composite
232
- * (`"abc123:3"` → `{ base: 'abc123', serial: 3 }`). Throws on
233
- * malformed input. Apply this to `access.id`, `access.createdBy`,
229
+ * Parse a wire-format access reference into `{ base, serial }`
230
+ * (Pryv.io 2.0.0-pre.X). Accepts both bare cuid (`"abc123"` →
231
+ * `{ base: 'abc123', serial: null }`) and composite (`"abc123:3"` →
232
+ * `{ base: 'abc123', serial: 3 }`). Throws on malformed input.
233
+ * Apply this to `access.id`, `access.createdBy`,
234
234
  * `access.modifiedBy`, and `streamIds` entries of the form
235
235
  * `access-<base>:<serial>` from audit events.
236
236
  * @memberof pryv.utils
@@ -256,9 +256,9 @@ const utils = module.exports = {
256
256
  },
257
257
 
258
258
  /**
259
- * Plan 66: render an `{ base, serial }` pair back to the wire
260
- * format. Bare cuid when serial is null/undefined; `<base>:<serial>`
261
- * otherwise. Mostly used to construct the composite id when calling
259
+ * Render an `{ base, serial }` pair back to the wire format. Bare
260
+ * cuid when serial is null/undefined; `<base>:<serial>` otherwise.
261
+ * Mostly used to construct the composite id when calling
262
262
  * `connection.api()` for `accesses.update` / `accesses.delete`.
263
263
  * @memberof pryv.utils
264
264
  * @param {{ base: string, serial?: number | null }} ref
@@ -190,4 +190,78 @@ describe('[ACNX] AuthController', function () {
190
190
  expect(auth.state.id).to.equal(AuthStates.LOADING); // retro-compatibility
191
191
  });
192
192
  });
193
+
194
+ describe('[AFLT] External listener filtering', function () {
195
+ it('[AFLA] AUTHORIZED state passes only { status, id, key, serviceInfo } to onStateChange', function () {
196
+ const seen = [];
197
+ const auth = new AuthController({
198
+ authRequest: {
199
+ requestingAppId: 'test-app',
200
+ requestedPermissions: []
201
+ },
202
+ onStateChange: (state) => seen.push(state)
203
+ }, service);
204
+
205
+ auth.state = {
206
+ status: AuthStates.AUTHORIZED,
207
+ key: 'abc123',
208
+ serviceInfo: { name: 'Test' },
209
+ apiEndpoint: 'https://token@example.com/',
210
+ username: 'alice',
211
+ token: 'secret-token'
212
+ };
213
+
214
+ const last = seen[seen.length - 1];
215
+ expect(last.status).to.equal(AuthStates.AUTHORIZED);
216
+ expect(last.id).to.equal(AuthStates.AUTHORIZED);
217
+ expect(last.key).to.equal('abc123');
218
+ expect(last.serviceInfo).to.deep.equal({ name: 'Test' });
219
+ // Credentials must not leak to the calling app.
220
+ expect(last.apiEndpoint).to.equal(undefined);
221
+ expect(last.username).to.equal(undefined);
222
+ expect(last.token).to.equal(undefined);
223
+ });
224
+
225
+ it('[AFLC] AUTHORIZED state from cookie-autologin (no key) passes through unchanged', function () {
226
+ // Mirrors `checkAutoLogin()`'s state shape: it spreads stored
227
+ // credentials into the AUTHORIZED state without a key, since the
228
+ // key is not persisted.
229
+ const seen = [];
230
+ const auth = new AuthController({
231
+ authRequest: {
232
+ requestingAppId: 'test-app',
233
+ requestedPermissions: []
234
+ },
235
+ onStateChange: (state) => seen.push(state)
236
+ }, service);
237
+
238
+ auth.state = {
239
+ status: AuthStates.AUTHORIZED,
240
+ apiEndpoint: 'https://token@example.com/',
241
+ username: 'alice'
242
+ };
243
+
244
+ const last = seen[seen.length - 1];
245
+ // Backwards-compat: existing pages building Connection directly
246
+ // from `state.apiEndpoint` on page reload keep working.
247
+ expect(last.apiEndpoint).to.equal('https://token@example.com/');
248
+ expect(last.username).to.equal('alice');
249
+ });
250
+
251
+ it('[AFLB] non-AUTHORIZED states pass through unchanged', function () {
252
+ const seen = [];
253
+ const auth = new AuthController({
254
+ authRequest: {
255
+ requestingAppId: 'test-app',
256
+ requestedPermissions: []
257
+ },
258
+ onStateChange: (state) => seen.push(state)
259
+ }, service);
260
+
261
+ auth.state = { status: AuthStates.ERROR, message: 'boom', error: new Error('e') };
262
+ const last = seen[seen.length - 1];
263
+ expect(last.status).to.equal(AuthStates.ERROR);
264
+ expect(last.message).to.equal('boom');
265
+ });
266
+ });
193
267
  });
@@ -500,7 +500,7 @@ describe('[CONX] Connection', () => {
500
500
  expect(newUser.access.token).to.equal(accessInfoUser.token);
501
501
  });
502
502
 
503
- // Plan 66 — accessInfo caching + forceRefresh.
503
+ // accessInfo caching + forceRefresh.
504
504
 
505
505
  it('[CAIC] accessInfo() memoizes — second call returns the same object reference', async () => {
506
506
  const regexAPIandToken = /(.+):\/\/(.+)/gm;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+ /* global describe, it, before, expect, pryv */
6
+
7
+ const { createId: cuid } = require('@paralleldrive/cuid2');
8
+ const testData = require('../../../test/test-data');
9
+
10
+ const buildSearchParams = require('../src/lib/buildSearchParams');
11
+ const resolveDotPath = require('../src/lib/resolveDotPath');
12
+
13
+ describe('[CQLJ] Content queries', () => {
14
+ describe('[CQLU] buildSearchParams structured values', () => {
15
+ it('[LU01] keeps scalar arrays as repeated keys', () => {
16
+ expect(buildSearchParams({ a: ['x', 'y'], b: 1 })).to.equal('a=x&a=y&b=1');
17
+ });
18
+
19
+ it('[LU02] JSON-encodes arrays of objects (conditions) as one parameter', () => {
20
+ const conditions = [{ path: 'drug.codes.atc', eq: 'G03DA04' }];
21
+ const out = buildSearchParams({ content: conditions });
22
+ expect(decodeURIComponent(out)).to.equal('content=' + JSON.stringify(conditions));
23
+ });
24
+
25
+ it('[LU03] JSON-encodes plain object values', () => {
26
+ const out = buildSearchParams({ streams: { any: ['a'] } });
27
+ expect(decodeURIComponent(out.replace(/\+/g, ' '))).to.equal('streams=' + JSON.stringify({ any: ['a'] }));
28
+ });
29
+ });
30
+
31
+ describe('[CQLR] resolveDotPath', () => {
32
+ it('[LR01] resolves nested paths, root $ and misses', () => {
33
+ const content = { drug: { codes: { atc: 'G03DA04' } }, taken: true };
34
+ expect(resolveDotPath(content, 'drug.codes.atc')).to.equal('G03DA04');
35
+ expect(resolveDotPath(content, 'taken')).to.equal(true);
36
+ expect(resolveDotPath(content, 'drug.codes.snomed')).to.equal(undefined);
37
+ expect(resolveDotPath(14.2, '$')).to.equal(14.2);
38
+ expect(resolveDotPath(undefined, 'a.b')).to.equal(undefined);
39
+ expect(resolveDotPath({ a: [1] }, 'a.0')).to.equal(undefined); // no array indices
40
+ });
41
+ });
42
+
43
+ describe('[CQLI] against a live platform (skipped when unsupported)', () => {
44
+ let conn, supported, streamId;
45
+ const codes = { progesterone: 'G03DA04', aspirin: 'B01AC06', missing: 'N02BE01' };
46
+
47
+ before(async function () {
48
+ this.timeout(15000);
49
+ await testData.prepare();
50
+ conn = new pryv.Connection(testData.apiEndpointWithToken);
51
+ supported = await conn.service.supportsContentQueries();
52
+ if (!supported) return;
53
+
54
+ streamId = 'cq-' + cuid().substring(0, 8);
55
+ const calls = [{ method: 'streams.create', params: { id: streamId, name: 'CQ ' + streamId, parentId: 'data' } }];
56
+ // two assertions per drug — older taken:false, newer taken:true — to verify "latest"
57
+ let time = 1700000000;
58
+ for (const code of [codes.progesterone, codes.aspirin]) {
59
+ calls.push({ method: 'events.create', params: { streamIds: [streamId], type: 'medication/exposure-assertion-v1', time: time++, content: { drug: { codes: { atc: code } }, taken: false } } });
60
+ calls.push({ method: 'events.create', params: { streamIds: [streamId], type: 'medication/exposure-assertion-v1', time: time++, content: { drug: { codes: { atc: code } }, taken: true } } });
61
+ }
62
+ const res = await conn.api(calls);
63
+ for (const r of res) expect(r.error).to.be.undefined;
64
+ });
65
+
66
+ it('[LI01] supportsContentQueries returns a boolean', async () => {
67
+ expect(typeof supported).to.equal('boolean');
68
+ });
69
+
70
+ it('[LI02] events.get accepts content conditions via GET (streamed)', async function () {
71
+ if (!supported) this.skip();
72
+ const collected = [];
73
+ await conn.getEventsStreamed(
74
+ { streams: [streamId], content: [{ path: 'drug.codes.atc', eq: codes.progesterone }, { path: 'taken', eq: true }] },
75
+ (event) => collected.push(event)
76
+ );
77
+ expect(collected.length).to.equal(1);
78
+ expect(collected[0].content.taken).to.equal(true);
79
+ });
80
+
81
+ it('[LI03] getLatestByContent returns the latest event per value', async function () {
82
+ if (!supported) this.skip();
83
+ const byCode = await conn.getLatestByContent(
84
+ 'drug.codes.atc',
85
+ [codes.progesterone, codes.aspirin, codes.missing],
86
+ { streams: [streamId] }
87
+ );
88
+ expect(byCode.size).to.equal(2);
89
+ expect(byCode.get(codes.missing)).to.be.undefined;
90
+ // latest per code is the taken:true one (created later)
91
+ expect(byCode.get(codes.progesterone).content.taken).to.equal(true);
92
+ expect(byCode.get(codes.aspirin).content.taken).to.equal(true);
93
+ });
94
+ });
95
+ });
@@ -38,6 +38,30 @@ describe('[ARQX] Service access-request init', function () {
38
38
  });
39
39
  });
40
40
 
41
+ describe('[CFKX] Service.connectFromKey', function () {
42
+ it('[CFKA] rejects when key is missing', async function () {
43
+ let caught;
44
+ try { await service.connectFromKey(); } catch (e) { caught = e; }
45
+ expect(caught).to.be.instanceOf(pryv.PryvError);
46
+ });
47
+
48
+ it('[CFKB] throws when the access is still NEED_SIGNIN', async function () {
49
+ this.timeout(15000);
50
+ const env = await service.startAccessRequest({
51
+ requestingAppId: 'jslib-test',
52
+ requestedPermissions: [{
53
+ streamId: 'data',
54
+ level: 'read',
55
+ defaultName: 'Test'
56
+ }]
57
+ });
58
+ let caught;
59
+ try { await service.connectFromKey(env.key); } catch (e) { caught = e; }
60
+ expect(caught).to.be.instanceOf(pryv.PryvError);
61
+ expect(caught.message).to.include('NEED_SIGNIN');
62
+ });
63
+ });
64
+
41
65
  describe('[APRX] Service.pollAccessRequest', function () {
42
66
  it('[APRA] rejects when key is missing', async function () {
43
67
  let caught;
@@ -60,7 +60,7 @@ describe('[UTLX] utils', function () {
60
60
  done();
61
61
  });
62
62
 
63
- // Plan 66 — composite access references.
63
+ // Composite access references.
64
64
 
65
65
  it('[UTLF] parseAccessRef on a bare cuid returns { base, serial: null }', function () {
66
66
  const ref = pryv.utils.parseAccessRef('abc123def456');