@oxyhq/core 3.4.19 → 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.
@@ -33,6 +33,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
33
33
  export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
34
34
  export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount, } from './mixins/OxyServices.managedAccounts';
35
35
  export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
36
+ export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, } from './mixins/OxyServices.user';
36
37
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
37
38
  export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity';
38
39
  export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle';
@@ -5,6 +5,38 @@ import type { User, Notification, NotificationPreferences, UserPreferences, Sear
5
5
  import type { UserNameResponse, UserProfileUpdate } from '@oxyhq/contracts';
6
6
  import type { OxyServicesBase } from '../OxyServices.base';
7
7
  import { type PaginationParams } from '../utils/apiUtils';
8
+ /** Per-user outcome returned by `POST /users/follow/bulk`. */
9
+ export interface BulkFollowEntry {
10
+ /** The user ID that was processed. */
11
+ userId: string;
12
+ /** Whether the follow was applied (or already in place) without error. */
13
+ success: boolean;
14
+ /** Whether the caller was already following this user before the request. */
15
+ alreadyFollowing: boolean;
16
+ }
17
+ /** Response shape of `POST /users/follow/bulk`. */
18
+ export interface BulkFollowResult {
19
+ /** Per-user outcomes, in request order. */
20
+ results: BulkFollowEntry[];
21
+ /** Number of users newly followed by this request. */
22
+ followedCount: number;
23
+ }
24
+ /** Per-user outcome returned by `POST /users/unfollow/bulk`. */
25
+ export interface BulkUnfollowEntry {
26
+ /** The user ID that was processed. */
27
+ userId: string;
28
+ /** Whether the unfollow was applied (or already absent) without error. */
29
+ success: boolean;
30
+ /** Whether the caller was following this user before the request. */
31
+ wasFollowing: boolean;
32
+ }
33
+ /** Response shape of `POST /users/unfollow/bulk`. */
34
+ export interface BulkUnfollowResult {
35
+ /** Per-user outcomes, in request order. */
36
+ results: BulkUnfollowEntry[];
37
+ /** Number of users newly unfollowed by this request. */
38
+ unfollowedCount: number;
39
+ }
8
40
  export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T): {
9
41
  new (...args: any[]): {
10
42
  /**
@@ -161,12 +193,37 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
161
193
  message: string;
162
194
  }>;
163
195
  /**
164
- * Follow a user
196
+ * Follow a user.
197
+ *
198
+ * Invalidates the cached `GET /users/<id>/follow-status` response after
199
+ * the write. `getFollowStatus` caches for ~1 minute (identity-scoped);
200
+ * without busting that entry, a `FollowButton` that remounts within the
201
+ * TTL window re-reads the STALE pre-write status and reverts the optimistic
202
+ * UI (the "follow resets after navigating away and back" bug).
203
+ * `clearCacheEntry` deletes every identity-scoped variant of the key.
165
204
  */
166
205
  followUser(userId: string): Promise<{
167
206
  success: boolean;
168
207
  message: string;
169
208
  }>;
209
+ /**
210
+ * Follow multiple users in a single request.
211
+ *
212
+ * POSTs `/users/follow/bulk` with `{ userIds }` (server caps the batch at
213
+ * 200). Returns the per-user outcomes and the count of users newly
214
+ * followed. An empty `userIds` array resolves immediately with an empty
215
+ * result and performs no network call.
216
+ */
217
+ followUsers(userIds: string[]): Promise<BulkFollowResult>;
218
+ /**
219
+ * Unfollow multiple users in a single request.
220
+ *
221
+ * POSTs `/users/unfollow/bulk` with `{ userIds }` (server caps the batch at
222
+ * 200). Returns the per-user outcomes and the count of users newly
223
+ * unfollowed. An empty `userIds` array resolves immediately with an empty
224
+ * result and performs no network call.
225
+ */
226
+ unfollowUsers(userIds: string[]): Promise<BulkUnfollowResult>;
170
227
  /**
171
228
  * Unfollow a user
172
229
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.4.19",
3
+ "version": "3.6.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
package/src/index.ts CHANGED
@@ -61,6 +61,12 @@ export type {
61
61
  ContactDiscoveryMatch,
62
62
  ContactDiscoveryResponse,
63
63
  } from './mixins/OxyServices.contacts';
64
+ export type {
65
+ BulkFollowEntry,
66
+ BulkFollowResult,
67
+ BulkUnfollowEntry,
68
+ BulkUnfollowResult,
69
+ } from './mixins/OxyServices.user';
64
70
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
65
71
 
66
72
  // ---------------------------------------------------------------------------
@@ -17,6 +17,42 @@ import { KeyManager } from '../crypto/keyManager';
17
17
  import { SignatureService } from '../crypto/signatureService';
18
18
  import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
19
19
 
20
+ /** Per-user outcome returned by `POST /users/follow/bulk`. */
21
+ export interface BulkFollowEntry {
22
+ /** The user ID that was processed. */
23
+ userId: string;
24
+ /** Whether the follow was applied (or already in place) without error. */
25
+ success: boolean;
26
+ /** Whether the caller was already following this user before the request. */
27
+ alreadyFollowing: boolean;
28
+ }
29
+
30
+ /** Response shape of `POST /users/follow/bulk`. */
31
+ export interface BulkFollowResult {
32
+ /** Per-user outcomes, in request order. */
33
+ results: BulkFollowEntry[];
34
+ /** Number of users newly followed by this request. */
35
+ followedCount: number;
36
+ }
37
+
38
+ /** Per-user outcome returned by `POST /users/unfollow/bulk`. */
39
+ export interface BulkUnfollowEntry {
40
+ /** The user ID that was processed. */
41
+ userId: string;
42
+ /** Whether the unfollow was applied (or already absent) without error. */
43
+ success: boolean;
44
+ /** Whether the caller was following this user before the request. */
45
+ wasFollowing: boolean;
46
+ }
47
+
48
+ /** Response shape of `POST /users/unfollow/bulk`. */
49
+ export interface BulkUnfollowResult {
50
+ /** Per-user outcomes, in request order. */
51
+ results: BulkUnfollowEntry[];
52
+ /** Number of users newly unfollowed by this request. */
53
+ unfollowedCount: number;
54
+ }
55
+
20
56
  export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T) {
21
57
  return class extends Base {
22
58
  constructor(...args: any[]) {
@@ -394,11 +430,68 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
394
430
 
395
431
 
396
432
  /**
397
- * Follow a user
433
+ * Follow a user.
434
+ *
435
+ * Invalidates the cached `GET /users/<id>/follow-status` response after
436
+ * the write. `getFollowStatus` caches for ~1 minute (identity-scoped);
437
+ * without busting that entry, a `FollowButton` that remounts within the
438
+ * TTL window re-reads the STALE pre-write status and reverts the optimistic
439
+ * UI (the "follow resets after navigating away and back" bug).
440
+ * `clearCacheEntry` deletes every identity-scoped variant of the key.
398
441
  */
399
442
  async followUser(userId: string): Promise<{ success: boolean; message: string }> {
400
443
  try {
401
- return await this.makeRequest('POST', `/users/${userId}/follow`, undefined, { cache: false });
444
+ const result = await this.makeRequest<{ success: boolean; message: string }>('POST', `/users/${userId}/follow`, undefined, { cache: false });
445
+ this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
446
+ return result;
447
+ } catch (error) {
448
+ throw this.handleError(error);
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Follow multiple users in a single request.
454
+ *
455
+ * POSTs `/users/follow/bulk` with `{ userIds }` (server caps the batch at
456
+ * 200). Returns the per-user outcomes and the count of users newly
457
+ * followed. An empty `userIds` array resolves immediately with an empty
458
+ * result and performs no network call.
459
+ */
460
+ async followUsers(userIds: string[]): Promise<BulkFollowResult> {
461
+ if (userIds.length === 0) {
462
+ return { results: [], followedCount: 0 };
463
+ }
464
+ try {
465
+ const result = await this.makeRequest<BulkFollowResult>('POST', '/users/follow/bulk', { userIds }, { cache: false });
466
+ // Bust each affected user's cached follow-status (see `followUser`).
467
+ for (const id of userIds) {
468
+ this.clearCacheEntry(`GET:/users/${id}/follow-status`);
469
+ }
470
+ return result;
471
+ } catch (error) {
472
+ throw this.handleError(error);
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Unfollow multiple users in a single request.
478
+ *
479
+ * POSTs `/users/unfollow/bulk` with `{ userIds }` (server caps the batch at
480
+ * 200). Returns the per-user outcomes and the count of users newly
481
+ * unfollowed. An empty `userIds` array resolves immediately with an empty
482
+ * result and performs no network call.
483
+ */
484
+ async unfollowUsers(userIds: string[]): Promise<BulkUnfollowResult> {
485
+ if (userIds.length === 0) {
486
+ return { results: [], unfollowedCount: 0 };
487
+ }
488
+ try {
489
+ const result = await this.makeRequest<BulkUnfollowResult>('POST', '/users/unfollow/bulk', { userIds }, { cache: false });
490
+ // Bust each affected user's cached follow-status (see `followUser`).
491
+ for (const id of userIds) {
492
+ this.clearCacheEntry(`GET:/users/${id}/follow-status`);
493
+ }
494
+ return result;
402
495
  } catch (error) {
403
496
  throw this.handleError(error);
404
497
  }
@@ -409,7 +502,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
409
502
  */
410
503
  async unfollowUser(userId: string): Promise<{ success: boolean; message: string }> {
411
504
  try {
412
- return await this.makeRequest('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
505
+ const result = await this.makeRequest<{ success: boolean; message: string }>('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
506
+ // Bust the cached follow-status so a remount reads fresh truth (see `followUser`).
507
+ this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
508
+ return result;
413
509
  } catch (error) {
414
510
  throw this.handleError(error);
415
511
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Follow-status cache invalidation tests.
3
+ *
4
+ * Regression coverage for the "follow resets after navigating away and back"
5
+ * bug. `getFollowStatus(userId)` caches `GET /users/<id>/follow-status` for
6
+ * ~1 minute (identity-scoped). A follow/unfollow write must INVALIDATE that
7
+ * cached entry, otherwise a `FollowButton` that remounts within the TTL window
8
+ * re-reads the STALE pre-write status and reverts the optimistic UI.
9
+ *
10
+ * These tests pin down that:
11
+ * - a cached `follow-status` is NOT served after `followUser` /
12
+ * `unfollowUser` / `followUsers` / `unfollowUsers` — the next read
13
+ * re-fetches and observes the new server truth,
14
+ * - each mutation invalidates the exact logical key(s) for the affected ids.
15
+ */
16
+
17
+ import { OxyServices } from '../../OxyServices';
18
+
19
+ /**
20
+ * Build a non-verified JWT whose payload decodes to the given claims.
21
+ * `jwtDecode` only base64url-decodes the middle segment (no signature check).
22
+ */
23
+ function makeJwt(payload: Record<string, unknown>): string {
24
+ const b64url = (obj: Record<string, unknown>): string =>
25
+ Buffer.from(JSON.stringify(obj)).toString('base64url');
26
+ const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
27
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
28
+ }
29
+
30
+ /** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
31
+ function jsonResponse(data: unknown): Response {
32
+ return new Response(JSON.stringify({ data }), {
33
+ status: 200,
34
+ headers: { 'content-type': 'application/json' },
35
+ });
36
+ }
37
+
38
+ describe('follow-status cache invalidation', () => {
39
+ let originalFetch: typeof globalThis.fetch;
40
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
41
+ let oxy: OxyServices;
42
+
43
+ beforeEach(() => {
44
+ originalFetch = globalThis.fetch;
45
+ fetchMock = jest.fn();
46
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
47
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
48
+ oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
49
+ });
50
+
51
+ afterEach(() => {
52
+ globalThis.fetch = originalFetch;
53
+ jest.clearAllMocks();
54
+ });
55
+
56
+ it('does NOT serve a stale cached follow-status after followUser', async () => {
57
+ // 1) Warm the cache: not following yet.
58
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
59
+ const before = await oxy.getFollowStatus('target-1');
60
+ expect(before.isFollowing).toBe(false);
61
+ expect(fetchMock).toHaveBeenCalledTimes(1);
62
+
63
+ // A second read within the TTL is a cache hit (no extra network call).
64
+ await oxy.getFollowStatus('target-1');
65
+ expect(fetchMock).toHaveBeenCalledTimes(1);
66
+
67
+ // 2) Follow — write succeeds and must bust the cached follow-status.
68
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
69
+ await oxy.followUser('target-1');
70
+ expect(fetchMock).toHaveBeenCalledTimes(2);
71
+
72
+ // 3) Remount-style re-read MUST re-fetch and observe the fresh truth.
73
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
74
+ const after = await oxy.getFollowStatus('target-1');
75
+ expect(fetchMock).toHaveBeenCalledTimes(3);
76
+ expect(after.isFollowing).toBe(true);
77
+ });
78
+
79
+ it('does NOT serve a stale cached follow-status after unfollowUser', async () => {
80
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
81
+ const before = await oxy.getFollowStatus('target-2');
82
+ expect(before.isFollowing).toBe(true);
83
+ expect(fetchMock).toHaveBeenCalledTimes(1);
84
+
85
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
86
+ await oxy.unfollowUser('target-2');
87
+ expect(fetchMock).toHaveBeenCalledTimes(2);
88
+
89
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
90
+ const after = await oxy.getFollowStatus('target-2');
91
+ expect(fetchMock).toHaveBeenCalledTimes(3);
92
+ expect(after.isFollowing).toBe(false);
93
+ });
94
+
95
+ it('busts cached follow-status for every id after followUsers (bulk)', async () => {
96
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
97
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
98
+ await oxy.getFollowStatus('a');
99
+ await oxy.getFollowStatus('b');
100
+ expect(fetchMock).toHaveBeenCalledTimes(2);
101
+
102
+ fetchMock.mockResolvedValueOnce(
103
+ jsonResponse({
104
+ results: [
105
+ { userId: 'a', success: true, alreadyFollowing: false },
106
+ { userId: 'b', success: true, alreadyFollowing: false },
107
+ ],
108
+ followedCount: 2,
109
+ }),
110
+ );
111
+ await oxy.followUsers(['a', 'b']);
112
+ expect(fetchMock).toHaveBeenCalledTimes(3);
113
+
114
+ // Both ids must re-fetch.
115
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
116
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
117
+ expect((await oxy.getFollowStatus('a')).isFollowing).toBe(true);
118
+ expect((await oxy.getFollowStatus('b')).isFollowing).toBe(true);
119
+ expect(fetchMock).toHaveBeenCalledTimes(5);
120
+ });
121
+
122
+ it('busts cached follow-status for every id after unfollowUsers (bulk)', async () => {
123
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
124
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: true }));
125
+ await oxy.getFollowStatus('a');
126
+ await oxy.getFollowStatus('b');
127
+ expect(fetchMock).toHaveBeenCalledTimes(2);
128
+
129
+ fetchMock.mockResolvedValueOnce(
130
+ jsonResponse({
131
+ results: [
132
+ { userId: 'a', success: true, wasFollowing: true },
133
+ { userId: 'b', success: true, wasFollowing: true },
134
+ ],
135
+ unfollowedCount: 2,
136
+ }),
137
+ );
138
+ await oxy.unfollowUsers(['a', 'b']);
139
+ expect(fetchMock).toHaveBeenCalledTimes(3);
140
+
141
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
142
+ fetchMock.mockResolvedValueOnce(jsonResponse({ isFollowing: false }));
143
+ expect((await oxy.getFollowStatus('a')).isFollowing).toBe(false);
144
+ expect((await oxy.getFollowStatus('b')).isFollowing).toBe(false);
145
+ expect(fetchMock).toHaveBeenCalledTimes(5);
146
+ });
147
+
148
+ it('invalidates the exact logical follow-status key on a single follow', async () => {
149
+ const clearSpy = jest.spyOn(oxy, 'clearCacheEntry');
150
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
151
+
152
+ await oxy.followUser('target-3');
153
+
154
+ expect(clearSpy).toHaveBeenCalledWith('GET:/users/target-3/follow-status');
155
+ clearSpy.mockRestore();
156
+ });
157
+
158
+ it('does not invalidate or call the network when bulk follow gets an empty list', async () => {
159
+ const clearSpy = jest.spyOn(oxy, 'clearCacheEntry');
160
+
161
+ const result = await oxy.followUsers([]);
162
+
163
+ expect(result).toEqual({ results: [], followedCount: 0 });
164
+ expect(fetchMock).not.toHaveBeenCalled();
165
+ expect(clearSpy).not.toHaveBeenCalled();
166
+ clearSpy.mockRestore();
167
+ });
168
+ });