@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.user.js +45 -4
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.user.js +45 -4
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.user.d.ts +33 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/mixins/OxyServices.user.ts +63 -4
- package/src/mixins/__tests__/followCacheInvalidation.test.ts +168 -0
package/dist/types/index.d.ts
CHANGED
|
@@ -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
package/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|