@oxyhq/core 3.5.0 → 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,7 +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, } from './mixins/OxyServices.user';
36
+ export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, } from './mixins/OxyServices.user';
37
37
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
38
38
  export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity';
39
39
  export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle';
@@ -21,6 +21,22 @@ export interface BulkFollowResult {
21
21
  /** Number of users newly followed by this request. */
22
22
  followedCount: number;
23
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
+ }
24
40
  export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T): {
25
41
  new (...args: any[]): {
26
42
  /**
@@ -177,7 +193,14 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
177
193
  message: string;
178
194
  }>;
179
195
  /**
180
- * 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.
181
204
  */
182
205
  followUser(userId: string): Promise<{
183
206
  success: boolean;
@@ -192,6 +215,15 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
192
215
  * result and performs no network call.
193
216
  */
194
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>;
195
227
  /**
196
228
  * Unfollow a user
197
229
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.5.0",
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
@@ -64,6 +64,8 @@ export type {
64
64
  export type {
65
65
  BulkFollowEntry,
66
66
  BulkFollowResult,
67
+ BulkUnfollowEntry,
68
+ BulkUnfollowResult,
67
69
  } from './mixins/OxyServices.user';
68
70
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
69
71
 
@@ -35,6 +35,24 @@ export interface BulkFollowResult {
35
35
  followedCount: number;
36
36
  }
37
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
+
38
56
  export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T) {
39
57
  return class extends Base {
40
58
  constructor(...args: any[]) {
@@ -412,11 +430,20 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
412
430
 
413
431
 
414
432
  /**
415
- * 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.
416
441
  */
417
442
  async followUser(userId: string): Promise<{ success: boolean; message: string }> {
418
443
  try {
419
- 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;
420
447
  } catch (error) {
421
448
  throw this.handleError(error);
422
449
  }
@@ -435,7 +462,36 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
435
462
  return { results: [], followedCount: 0 };
436
463
  }
437
464
  try {
438
- return await this.makeRequest<BulkFollowResult>('POST', '/users/follow/bulk', { userIds }, { cache: false });
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;
439
495
  } catch (error) {
440
496
  throw this.handleError(error);
441
497
  }
@@ -446,7 +502,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
446
502
  */
447
503
  async unfollowUser(userId: string): Promise<{ success: boolean; message: string }> {
448
504
  try {
449
- 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;
450
509
  } catch (error) {
451
510
  throw this.handleError(error);
452
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
+ });