@oxyhq/core 3.5.0 → 3.7.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/HttpService.js +102 -66
- package/dist/cjs/mixins/OxyServices.assets.js +34 -2
- package/dist/cjs/mixins/OxyServices.user.js +123 -18
- package/dist/cjs/utils/cacheKey.js +87 -0
- package/dist/cjs/utils/errorUtils.js +25 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +101 -65
- package/dist/esm/mixins/OxyServices.assets.js +34 -2
- package/dist/esm/mixins/OxyServices.user.js +123 -18
- package/dist/esm/utils/cacheKey.js +82 -0
- package/dist/esm/utils/errorUtils.js +24 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +24 -16
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.assets.d.ts +24 -1
- package/dist/types/mixins/OxyServices.user.d.ts +54 -28
- package/dist/types/utils/cacheKey.d.ts +67 -0
- package/dist/types/utils/errorUtils.d.ts +12 -0
- package/package.json +1 -1
- package/src/HttpService.ts +116 -67
- package/src/__tests__/authManager.cookiePath.test.ts +2 -2
- package/src/__tests__/authManager.security.test.ts +2 -2
- package/src/__tests__/httpServiceCache.test.ts +71 -0
- package/src/index.ts +2 -0
- package/src/mixins/OxyServices.assets.ts +36 -2
- package/src/mixins/OxyServices.user.ts +167 -36
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
- package/src/mixins/__tests__/followCacheInvalidation.test.ts +168 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +83 -0
- package/src/mixins/__tests__/sso.test.ts +13 -3
- package/src/utils/__tests__/cacheKey.test.ts +0 -0
- package/src/utils/__tests__/coldBoot.test.ts +125 -0
- package/src/utils/cacheKey.ts +98 -0
- package/src/utils/errorUtils.ts +25 -0
|
@@ -195,4 +195,75 @@ describe('HttpService identity-scoped response cache', () => {
|
|
|
195
195
|
await http.get('/users/u1', { cache: true });
|
|
196
196
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The soft cache-size guard is observability-only: it must warn (once,
|
|
201
|
+
* throttled) when the entry count blows past the soft ceiling, but must NEVER
|
|
202
|
+
* evict a live entry. We synthesize bloat by giving every request a distinct
|
|
203
|
+
* undecodable token, so each write mints a fresh identity tag → a fresh key.
|
|
204
|
+
*/
|
|
205
|
+
describe('soft cache-size telemetry guard', () => {
|
|
206
|
+
/** Number of distinct cached entries needed to cross the 500 soft ceiling. */
|
|
207
|
+
const ENTRIES_PAST_LIMIT = 520;
|
|
208
|
+
|
|
209
|
+
const newLoggingService = (): HttpService =>
|
|
210
|
+
new HttpService({
|
|
211
|
+
baseURL: 'http://test.invalid',
|
|
212
|
+
enableRetry: false,
|
|
213
|
+
requestTimeout: 1000,
|
|
214
|
+
enableLogging: true,
|
|
215
|
+
logLevel: 'warn',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
let warnSpy: jest.SpyInstance;
|
|
219
|
+
beforeEach(() => {
|
|
220
|
+
// SimpleLogger routes `warn` to console.warn; spy there to assert telemetry.
|
|
221
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
222
|
+
});
|
|
223
|
+
afterEach(() => {
|
|
224
|
+
warnSpy.mockRestore();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('warns (throttled) once the cache exceeds the soft entry limit and never evicts', async () => {
|
|
228
|
+
const http = newLoggingService();
|
|
229
|
+
|
|
230
|
+
// Each iteration: a fresh undecodable token → fresh identity tag → fresh
|
|
231
|
+
// key, so the cache grows by one live entry per call.
|
|
232
|
+
for (let i = 0; i < ENTRIES_PAST_LIMIT; i++) {
|
|
233
|
+
http.setTokens(`undecodable-token-${i}`);
|
|
234
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ i }));
|
|
235
|
+
await http.get('/profiles/recommendations', { cache: true });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// No eviction: every distinct entry is still resident.
|
|
239
|
+
expect(http.getCacheStats().size).toBe(ENTRIES_PAST_LIMIT);
|
|
240
|
+
|
|
241
|
+
// Telemetry fired, and exactly once within the throttle window.
|
|
242
|
+
const cacheWarnings = warnSpy.mock.calls.filter((args) =>
|
|
243
|
+
args.some(
|
|
244
|
+
(a: unknown) =>
|
|
245
|
+
typeof a === 'string' && a.includes('exceeded soft entry limit'),
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
expect(cacheWarnings.length).toBe(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('does not warn while the cache stays under the soft limit', async () => {
|
|
252
|
+
const http = newLoggingService();
|
|
253
|
+
|
|
254
|
+
for (let i = 0; i < 10; i++) {
|
|
255
|
+
http.setTokens(`undecodable-token-${i}`);
|
|
256
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ i }));
|
|
257
|
+
await http.get('/profiles/recommendations', { cache: true });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const cacheWarnings = warnSpy.mock.calls.filter((args) =>
|
|
261
|
+
args.some(
|
|
262
|
+
(a: unknown) =>
|
|
263
|
+
typeof a === 'string' && a.includes('exceeded soft entry limit'),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
expect(cacheWarnings.length).toBe(0);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
198
269
|
});
|
package/src/index.ts
CHANGED
|
@@ -19,15 +19,49 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* Build a synchronous file URL from an Oxy asset id.
|
|
23
|
+
*
|
|
24
|
+
* This is the single chokepoint every Oxy app uses to turn a stored file id
|
|
25
|
+
* (avatars, post media, etc.) into a `<img src>`-ready URL, so it resolves to
|
|
26
|
+
* one of two forms depending on whether the caller needs a signed/private URL:
|
|
27
|
+
*
|
|
28
|
+
* - **Public asset (default)** — no access token planted on the client AND no
|
|
29
|
+
* `expiresIn` requested → returns the clean CDN form
|
|
30
|
+
* `${cloudURL}/<id>[?variant=...]` (e.g. `https://cloud.oxy.so/<id>?variant=thumb`).
|
|
31
|
+
* CloudFront resolves the id against the public media origin. No token,
|
|
32
|
+
* `fallback`, or origin query params are emitted — these URLs are cacheable
|
|
33
|
+
* and shareable.
|
|
34
|
+
* - **Signed / private asset** — an access token is present on the client OR
|
|
35
|
+
* `expiresIn` was passed (the caller explicitly wants an expiring/authorized
|
|
36
|
+
* URL) → keeps the authenticated origin form
|
|
37
|
+
* `${baseURL}/assets/<id>/stream?...&token=...`. Private assets are NOT on
|
|
38
|
+
* the public CDN, so they must go through the API origin that can authorize
|
|
39
|
+
* the request.
|
|
40
|
+
*
|
|
41
|
+
* `cloudURL` (default `https://cloud.oxy.so`) is configured once on the
|
|
42
|
+
* `OxyServices` constructor and read via `getCloudURL()`; the API origin is
|
|
43
|
+
* `getBaseURL()` (e.g. `https://api.oxy.so`).
|
|
44
|
+
*
|
|
45
|
+
* For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
|
|
23
46
|
*/
|
|
24
47
|
getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number): string {
|
|
48
|
+
const token = this.getClient().getAccessToken();
|
|
49
|
+
|
|
50
|
+
// Public case: no auth token and no expiry requested → clean CDN URL.
|
|
51
|
+
// CloudFront serves the public media origin under `${cloudURL}/<id>`.
|
|
52
|
+
if (!token && !expiresIn) {
|
|
53
|
+
const variantQs = variant ? `?variant=${encodeURIComponent(variant)}` : '';
|
|
54
|
+
return `${this.getCloudURL()}/${encodeURIComponent(fileId)}${variantQs}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Signed / private case: route through the authenticated API origin's
|
|
58
|
+
// stream endpoint so the request can be authorized (private assets are not
|
|
59
|
+
// exposed on the public CDN).
|
|
25
60
|
const base = this.getBaseURL();
|
|
26
61
|
const params = new URLSearchParams();
|
|
27
62
|
if (variant) params.set('variant', variant);
|
|
28
63
|
if (expiresIn) params.set('expiresIn', String(expiresIn));
|
|
29
64
|
params.set('fallback', 'placeholderVisible');
|
|
30
|
-
const token = this.getClient().getAccessToken();
|
|
31
65
|
if (token) params.set('token', token);
|
|
32
66
|
|
|
33
67
|
const qs = params.toString();
|
|
@@ -10,12 +10,20 @@ import type {
|
|
|
10
10
|
PaginationInfo,
|
|
11
11
|
PrivacySettings,
|
|
12
12
|
} from '../models/interfaces';
|
|
13
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
UserNameResponse,
|
|
15
|
+
UserProfileUpdate,
|
|
16
|
+
RecommendationRequest,
|
|
17
|
+
RecommendationItem,
|
|
18
|
+
} from '@oxyhq/contracts';
|
|
19
|
+
import { recommendationRequestSchema } from '@oxyhq/contracts';
|
|
14
20
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
15
21
|
import { buildSearchParams, buildPaginationParams, type PaginationParams } from '../utils/apiUtils';
|
|
16
22
|
import { KeyManager } from '../crypto/keyManager';
|
|
17
23
|
import { SignatureService } from '../crypto/signatureService';
|
|
18
24
|
import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
|
|
25
|
+
import { logger } from '../utils/loggerUtils';
|
|
26
|
+
import { extractErrorStatus } from '../utils/errorUtils';
|
|
19
27
|
|
|
20
28
|
/** Per-user outcome returned by `POST /users/follow/bulk`. */
|
|
21
29
|
export interface BulkFollowEntry {
|
|
@@ -35,6 +43,24 @@ export interface BulkFollowResult {
|
|
|
35
43
|
followedCount: number;
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
/** Per-user outcome returned by `POST /users/unfollow/bulk`. */
|
|
47
|
+
export interface BulkUnfollowEntry {
|
|
48
|
+
/** The user ID that was processed. */
|
|
49
|
+
userId: string;
|
|
50
|
+
/** Whether the unfollow was applied (or already absent) without error. */
|
|
51
|
+
success: boolean;
|
|
52
|
+
/** Whether the caller was following this user before the request. */
|
|
53
|
+
wasFollowing: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Response shape of `POST /users/unfollow/bulk`. */
|
|
57
|
+
export interface BulkUnfollowResult {
|
|
58
|
+
/** Per-user outcomes, in request order. */
|
|
59
|
+
results: BulkUnfollowEntry[];
|
|
60
|
+
/** Number of users newly unfollowed by this request. */
|
|
61
|
+
unfollowedCount: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
39
65
|
return class extends Base {
|
|
40
66
|
constructor(...args: any[]) {
|
|
@@ -131,7 +157,26 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
131
157
|
cacheTTL: 24 * 60 * 60 * 1000, // 24h cache — matches server-side staleness window
|
|
132
158
|
});
|
|
133
159
|
return normalizeUserIdentityOrNull(result);
|
|
134
|
-
} catch {
|
|
160
|
+
} catch (error: unknown) {
|
|
161
|
+
// Discovery is best-effort: an unresolvable handle is a normal "not
|
|
162
|
+
// found", not an exceptional condition, so the contract stays `null`.
|
|
163
|
+
// But a 404 (handle genuinely absent) must be distinguishable from a
|
|
164
|
+
// network/server failure (WebFinger upstream down, 5xx) for debugging —
|
|
165
|
+
// both used to be swallowed identically. Log at `debug` with context so
|
|
166
|
+
// the distinction is observable without turning expected misses into
|
|
167
|
+
// noise. Return contract is unchanged.
|
|
168
|
+
const status = extractErrorStatus(error);
|
|
169
|
+
const isNotFound = status === 404;
|
|
170
|
+
logger.debug(
|
|
171
|
+
isNotFound ? 'resolveProfile: handle not found' : 'resolveProfile: discovery failed',
|
|
172
|
+
{
|
|
173
|
+
method: 'resolveProfile',
|
|
174
|
+
handle,
|
|
175
|
+
status,
|
|
176
|
+
notFound: isNotFound,
|
|
177
|
+
error: error instanceof Error ? error.message : String(error),
|
|
178
|
+
},
|
|
179
|
+
);
|
|
135
180
|
return null;
|
|
136
181
|
}
|
|
137
182
|
}
|
|
@@ -155,7 +200,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
155
200
|
}
|
|
156
201
|
|
|
157
202
|
/**
|
|
158
|
-
* Get profile recommendations
|
|
203
|
+
* Get profile recommendations.
|
|
159
204
|
*
|
|
160
205
|
* Public discovery read — works WITHOUT authentication. The SDK attaches the
|
|
161
206
|
* access token automatically when one is available (personalized via
|
|
@@ -163,29 +208,81 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
163
208
|
* the caller is logged out. This deliberately does NOT use `withAuthRetry`,
|
|
164
209
|
* which would throw an authentication timeout for logged-out callers before
|
|
165
210
|
* the request is ever sent.
|
|
211
|
+
*
|
|
212
|
+
* Routing (two server endpoints, both validated against the same
|
|
213
|
+
* `@oxyhq/contracts` recommendation schemas so the wire shape cannot drift):
|
|
214
|
+
*
|
|
215
|
+
* - **GET `/profiles/recommendations`** (cached) is used for the simple,
|
|
216
|
+
* back-compatible case: no options at all, or only `excludeTypes` and/or
|
|
217
|
+
* `limit`. `excludeTypes` is sent as a comma-joined query param and
|
|
218
|
+
* `limit` as a numeric query param — byte-for-byte identical to the legacy
|
|
219
|
+
* behavior so every existing caller keeps the same request and cache key.
|
|
220
|
+
* - **POST `/profiles/recommendations`** is used whenever any of the scored
|
|
221
|
+
* (v2) fields — `boosts`, `excludeIds`, `signalWeights`, `clientId`, or
|
|
222
|
+
* `offset` — is present. The full options object is validated with
|
|
223
|
+
* `recommendationRequestSchema` and sent as the request body. The POST is
|
|
224
|
+
* cached by the HttpService keyed on the serialized body, so repeated
|
|
225
|
+
* identical scored requests are deduplicated/cached just like the GET path.
|
|
226
|
+
*
|
|
227
|
+
* @param options - {@link RecommendationRequest} from `@oxyhq/contracts`.
|
|
228
|
+
* Omitted entirely (or `{ excludeTypes }`) preserves the legacy GET path.
|
|
166
229
|
*/
|
|
167
|
-
async getProfileRecommendations(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
[key: string]: unknown;
|
|
182
|
-
}>> {
|
|
230
|
+
async getProfileRecommendations(
|
|
231
|
+
options?: RecommendationRequest,
|
|
232
|
+
): Promise<RecommendationItem[]> {
|
|
233
|
+
// The scored (v2) POST path is selected when any field beyond the
|
|
234
|
+
// legacy GET-supported `excludeTypes`/`limit` pair is present.
|
|
235
|
+
const usesScoredPath = Boolean(
|
|
236
|
+
options &&
|
|
237
|
+
(options.clientId !== undefined ||
|
|
238
|
+
options.offset !== undefined ||
|
|
239
|
+
(options.excludeIds?.length ?? 0) > 0 ||
|
|
240
|
+
(options.boosts?.length ?? 0) > 0 ||
|
|
241
|
+
options.signalWeights !== undefined),
|
|
242
|
+
);
|
|
243
|
+
|
|
183
244
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
245
|
+
if (usesScoredPath && options) {
|
|
246
|
+
// Validate the full request against the shared contract before
|
|
247
|
+
// sending. A malformed payload is surfaced to the caller rather than
|
|
248
|
+
// bounced by the server, and the parsed value strips unknown keys.
|
|
249
|
+
const body = recommendationRequestSchema.parse(options);
|
|
250
|
+
return await this.makeRequest<RecommendationItem[]>(
|
|
251
|
+
'POST',
|
|
252
|
+
'/profiles/recommendations',
|
|
253
|
+
body,
|
|
254
|
+
// Cache keyed on the serialized body (see HttpService.generateCacheKey)
|
|
255
|
+
// so identical scored requests are served from cache, matching the
|
|
256
|
+
// GET path's caching semantics.
|
|
257
|
+
{ cache: true },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const params: Record<string, string> = {};
|
|
262
|
+
if (options?.excludeTypes?.length) {
|
|
263
|
+
params.excludeTypes = options.excludeTypes.join(',');
|
|
264
|
+
}
|
|
265
|
+
if (options?.limit !== undefined) {
|
|
266
|
+
params.limit = String(options.limit);
|
|
267
|
+
}
|
|
268
|
+
return await this.makeRequest<RecommendationItem[]>(
|
|
269
|
+
'GET',
|
|
270
|
+
'/profiles/recommendations',
|
|
271
|
+
Object.keys(params).length > 0 ? params : undefined,
|
|
272
|
+
{ cache: true },
|
|
273
|
+
);
|
|
274
|
+
} catch (error: unknown) {
|
|
275
|
+
// Recommendations are a discovery read; failures are surfaced to the
|
|
276
|
+
// caller (contract unchanged: rethrow via `handleError`). Add debug
|
|
277
|
+
// observability first so a recurring upstream failure is diagnosable
|
|
278
|
+
// — distinguishing an auth/transport problem from a server 5xx.
|
|
279
|
+
logger.debug('getProfileRecommendations: discovery read failed', {
|
|
280
|
+
method: 'getProfileRecommendations',
|
|
281
|
+
path: usesScoredPath ? 'POST' : 'GET',
|
|
282
|
+
excludeTypes: options?.excludeTypes,
|
|
283
|
+
status: extractErrorStatus(error),
|
|
284
|
+
error: error instanceof Error ? error.message : String(error),
|
|
285
|
+
});
|
|
189
286
|
throw this.handleError(error);
|
|
190
287
|
}
|
|
191
288
|
}
|
|
@@ -263,14 +360,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
263
360
|
return result;
|
|
264
361
|
} catch (error) {
|
|
265
362
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
266
|
-
const
|
|
267
|
-
? error as { status?: unknown; response?: { status?: unknown } }
|
|
268
|
-
: null;
|
|
269
|
-
const status = typeof errorRecord?.status === 'number'
|
|
270
|
-
? errorRecord.status
|
|
271
|
-
: typeof errorRecord?.response?.status === 'number'
|
|
272
|
-
? errorRecord.response.status
|
|
273
|
-
: undefined;
|
|
363
|
+
const status = extractErrorStatus(error);
|
|
274
364
|
|
|
275
365
|
// Check if it's an authentication error (401)
|
|
276
366
|
const isAuthError = status === 401 ||
|
|
@@ -412,11 +502,20 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
412
502
|
|
|
413
503
|
|
|
414
504
|
/**
|
|
415
|
-
* Follow a user
|
|
505
|
+
* Follow a user.
|
|
506
|
+
*
|
|
507
|
+
* Invalidates the cached `GET /users/<id>/follow-status` response after
|
|
508
|
+
* the write. `getFollowStatus` caches for ~1 minute (identity-scoped);
|
|
509
|
+
* without busting that entry, a `FollowButton` that remounts within the
|
|
510
|
+
* TTL window re-reads the STALE pre-write status and reverts the optimistic
|
|
511
|
+
* UI (the "follow resets after navigating away and back" bug).
|
|
512
|
+
* `clearCacheEntry` deletes every identity-scoped variant of the key.
|
|
416
513
|
*/
|
|
417
514
|
async followUser(userId: string): Promise<{ success: boolean; message: string }> {
|
|
418
515
|
try {
|
|
419
|
-
|
|
516
|
+
const result = await this.makeRequest<{ success: boolean; message: string }>('POST', `/users/${userId}/follow`, undefined, { cache: false });
|
|
517
|
+
this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
|
|
518
|
+
return result;
|
|
420
519
|
} catch (error) {
|
|
421
520
|
throw this.handleError(error);
|
|
422
521
|
}
|
|
@@ -435,7 +534,36 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
435
534
|
return { results: [], followedCount: 0 };
|
|
436
535
|
}
|
|
437
536
|
try {
|
|
438
|
-
|
|
537
|
+
const result = await this.makeRequest<BulkFollowResult>('POST', '/users/follow/bulk', { userIds }, { cache: false });
|
|
538
|
+
// Bust each affected user's cached follow-status (see `followUser`).
|
|
539
|
+
for (const id of userIds) {
|
|
540
|
+
this.clearCacheEntry(`GET:/users/${id}/follow-status`);
|
|
541
|
+
}
|
|
542
|
+
return result;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
throw this.handleError(error);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Unfollow multiple users in a single request.
|
|
550
|
+
*
|
|
551
|
+
* POSTs `/users/unfollow/bulk` with `{ userIds }` (server caps the batch at
|
|
552
|
+
* 200). Returns the per-user outcomes and the count of users newly
|
|
553
|
+
* unfollowed. An empty `userIds` array resolves immediately with an empty
|
|
554
|
+
* result and performs no network call.
|
|
555
|
+
*/
|
|
556
|
+
async unfollowUsers(userIds: string[]): Promise<BulkUnfollowResult> {
|
|
557
|
+
if (userIds.length === 0) {
|
|
558
|
+
return { results: [], unfollowedCount: 0 };
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
const result = await this.makeRequest<BulkUnfollowResult>('POST', '/users/unfollow/bulk', { userIds }, { cache: false });
|
|
562
|
+
// Bust each affected user's cached follow-status (see `followUser`).
|
|
563
|
+
for (const id of userIds) {
|
|
564
|
+
this.clearCacheEntry(`GET:/users/${id}/follow-status`);
|
|
565
|
+
}
|
|
566
|
+
return result;
|
|
439
567
|
} catch (error) {
|
|
440
568
|
throw this.handleError(error);
|
|
441
569
|
}
|
|
@@ -446,7 +574,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
446
574
|
*/
|
|
447
575
|
async unfollowUser(userId: string): Promise<{ success: boolean; message: string }> {
|
|
448
576
|
try {
|
|
449
|
-
|
|
577
|
+
const result = await this.makeRequest<{ success: boolean; message: string }>('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
|
|
578
|
+
// Bust the cached follow-status so a remount reads fresh truth (see `followUser`).
|
|
579
|
+
this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
|
|
580
|
+
return result;
|
|
450
581
|
} catch (error) {
|
|
451
582
|
throw this.handleError(error);
|
|
452
583
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery error-handling tests (C3).
|
|
3
|
+
*
|
|
4
|
+
* `resolveProfile` and `getProfileRecommendations` are best-effort discovery
|
|
5
|
+
* reads. Previously they swallowed every failure identically (`catch { return
|
|
6
|
+
* null }`), making a 404 "handle not found" indistinguishable from a 5xx /
|
|
7
|
+
* network failure in the field. These tests pin down the hardened behavior:
|
|
8
|
+
*
|
|
9
|
+
* - `resolveProfile` STILL returns `null` on any failure (contract unchanged),
|
|
10
|
+
* but emits a `debug` log that flags whether it was a not-found vs a failure.
|
|
11
|
+
* - `getProfileRecommendations` STILL rethrows (contract unchanged), but emits
|
|
12
|
+
* a `debug` log first for observability.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { OxyServices } from '../../OxyServices';
|
|
16
|
+
import { logger } from '../../utils/loggerUtils';
|
|
17
|
+
|
|
18
|
+
/** A JSON success `Response` mimicking the API's `{ data: ... }` envelope. */
|
|
19
|
+
function jsonResponse(data: unknown): Response {
|
|
20
|
+
return new Response(JSON.stringify({ data }), {
|
|
21
|
+
status: 200,
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a non-verified JWT whose payload decodes to the given claims.
|
|
28
|
+
* `jwtDecode` only base64url-decodes the middle segment (no signature check).
|
|
29
|
+
* Used to put the SDK in an authenticated state so a state-changing POST
|
|
30
|
+
* carries a bearer header and skips the CSRF-token pre-fetch.
|
|
31
|
+
*/
|
|
32
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
33
|
+
const b64url = (obj: Record<string, unknown>): string =>
|
|
34
|
+
Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
35
|
+
const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
|
|
36
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A JSON error `Response` with the given HTTP status. */
|
|
40
|
+
function errorResponse(status: number, message: string): Response {
|
|
41
|
+
return new Response(JSON.stringify({ message }), {
|
|
42
|
+
status,
|
|
43
|
+
headers: { 'content-type': 'application/json' },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('discovery error handling', () => {
|
|
48
|
+
let originalFetch: typeof globalThis.fetch;
|
|
49
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
50
|
+
let debugSpy: jest.SpyInstance;
|
|
51
|
+
let oxy: OxyServices;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
originalFetch = globalThis.fetch;
|
|
55
|
+
fetchMock = jest.fn();
|
|
56
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
57
|
+
debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => {});
|
|
58
|
+
// Disable retry so a single error response surfaces immediately.
|
|
59
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid', enableRetry: false });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
globalThis.fetch = originalFetch;
|
|
64
|
+
debugSpy.mockRestore();
|
|
65
|
+
jest.clearAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('resolveProfile', () => {
|
|
69
|
+
it('returns the normalized user when discovery succeeds', async () => {
|
|
70
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'u1', username: 'remote' }));
|
|
71
|
+
const result = await oxy.resolveProfile('remote@mastodon.social');
|
|
72
|
+
expect(result?.id).toBe('u1');
|
|
73
|
+
expect(debugSpy).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns null AND logs a not-found debug entry on 404', async () => {
|
|
77
|
+
fetchMock.mockResolvedValueOnce(errorResponse(404, 'not found'));
|
|
78
|
+
const result = await oxy.resolveProfile('ghost@nowhere.example');
|
|
79
|
+
expect(result).toBeNull();
|
|
80
|
+
expect(debugSpy).toHaveBeenCalledTimes(1);
|
|
81
|
+
const [message, context] = debugSpy.mock.calls[0];
|
|
82
|
+
expect(message).toContain('not found');
|
|
83
|
+
expect(context).toMatchObject({
|
|
84
|
+
method: 'resolveProfile',
|
|
85
|
+
handle: 'ghost@nowhere.example',
|
|
86
|
+
status: 404,
|
|
87
|
+
notFound: true,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns null AND logs a failure (not not-found) debug entry on 5xx', async () => {
|
|
92
|
+
fetchMock.mockResolvedValueOnce(errorResponse(503, 'upstream down'));
|
|
93
|
+
const result = await oxy.resolveProfile('remote@down.example');
|
|
94
|
+
expect(result).toBeNull();
|
|
95
|
+
expect(debugSpy).toHaveBeenCalledTimes(1);
|
|
96
|
+
const [message, context] = debugSpy.mock.calls[0];
|
|
97
|
+
expect(message).toContain('discovery failed');
|
|
98
|
+
expect(context).toMatchObject({
|
|
99
|
+
method: 'resolveProfile',
|
|
100
|
+
status: 503,
|
|
101
|
+
notFound: false,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns null on a network failure (no status)', async () => {
|
|
106
|
+
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
|
107
|
+
const result = await oxy.resolveProfile('remote@offline.example');
|
|
108
|
+
expect(result).toBeNull();
|
|
109
|
+
expect(debugSpy).toHaveBeenCalledTimes(1);
|
|
110
|
+
const [, context] = debugSpy.mock.calls[0];
|
|
111
|
+
expect(context).toMatchObject({ notFound: false });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('getProfileRecommendations', () => {
|
|
116
|
+
it('returns the list on success without logging', async () => {
|
|
117
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 'r1', username: 'rec' }]));
|
|
118
|
+
const result = await oxy.getProfileRecommendations();
|
|
119
|
+
expect(result).toHaveLength(1);
|
|
120
|
+
expect(debugSpy).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rethrows on failure AND logs a debug entry first (contract unchanged)', async () => {
|
|
124
|
+
fetchMock.mockResolvedValueOnce(errorResponse(500, 'boom'));
|
|
125
|
+
await expect(oxy.getProfileRecommendations()).rejects.toThrow();
|
|
126
|
+
expect(debugSpy).toHaveBeenCalledTimes(1);
|
|
127
|
+
const [message, context] = debugSpy.mock.calls[0];
|
|
128
|
+
expect(message).toContain('discovery read failed');
|
|
129
|
+
expect(context).toMatchObject({
|
|
130
|
+
method: 'getProfileRecommendations',
|
|
131
|
+
status: 500,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* GET-vs-POST routing for the recommendation contract (Wave 2).
|
|
138
|
+
*
|
|
139
|
+
* The simple/back-compat case (no options, or only `excludeTypes`/`limit`)
|
|
140
|
+
* keeps using the cached `GET /profiles/recommendations`. The scored (v2)
|
|
141
|
+
* fields (`clientId`/`offset`/`excludeIds`/`boosts`/`signalWeights`) switch to
|
|
142
|
+
* `POST /profiles/recommendations` with the validated request body.
|
|
143
|
+
*/
|
|
144
|
+
describe('getProfileRecommendations routing (GET vs POST)', () => {
|
|
145
|
+
// Authenticate so the state-changing POST carries a bearer header and the
|
|
146
|
+
// SDK skips the CSRF-token pre-fetch (which would otherwise consume a mock).
|
|
147
|
+
// A bearer token does not affect GET routing or the request body, so the
|
|
148
|
+
// GET-path assertions below are unaffected.
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/** The `(url, init)` pair of the single recorded fetch call. */
|
|
154
|
+
function lastCall(): { url: string; init: RequestInit | undefined } {
|
|
155
|
+
const [input, init] = fetchMock.mock.calls[fetchMock.mock.calls.length - 1];
|
|
156
|
+
return { url: String(input), init };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it('uses GET (no body) when called with no options', async () => {
|
|
160
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 'r1' }]));
|
|
161
|
+
await oxy.getProfileRecommendations();
|
|
162
|
+
const { url, init } = lastCall();
|
|
163
|
+
expect(init?.method).toBe('GET');
|
|
164
|
+
expect(init?.body).toBeUndefined();
|
|
165
|
+
expect(url).toContain('/profiles/recommendations');
|
|
166
|
+
expect(url).not.toContain('excludeTypes');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('uses GET with excludeTypes as a comma-joined query param (legacy shape)', async () => {
|
|
170
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 'r1' }]));
|
|
171
|
+
await oxy.getProfileRecommendations({ excludeTypes: ['federated', 'agent'] });
|
|
172
|
+
const { url, init } = lastCall();
|
|
173
|
+
expect(init?.method).toBe('GET');
|
|
174
|
+
expect(init?.body).toBeUndefined();
|
|
175
|
+
// Comma is URL-encoded as %2C in the query string.
|
|
176
|
+
expect(decodeURIComponent(url)).toContain('excludeTypes=federated,agent');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('uses GET with limit as a query param', async () => {
|
|
180
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 'r1' }]));
|
|
181
|
+
await oxy.getProfileRecommendations({ limit: 5 });
|
|
182
|
+
const { url, init } = lastCall();
|
|
183
|
+
expect(init?.method).toBe('GET');
|
|
184
|
+
expect(url).toContain('limit=5');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('uses POST with the validated body when clientId is present', async () => {
|
|
188
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 'r1' }]));
|
|
189
|
+
await oxy.getProfileRecommendations({ clientId: 'app-123', limit: 10 });
|
|
190
|
+
const { url, init } = lastCall();
|
|
191
|
+
expect(init?.method).toBe('POST');
|
|
192
|
+
expect(url).toContain('/profiles/recommendations');
|
|
193
|
+
expect(url).not.toContain('?');
|
|
194
|
+
expect(JSON.parse(String(init?.body))).toMatchObject({ clientId: 'app-123', limit: 10 });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('uses POST when boosts are present', async () => {
|
|
198
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 'r1' }]));
|
|
199
|
+
await oxy.getProfileRecommendations({
|
|
200
|
+
boosts: [{ userIds: ['u1', 'u2'], weight: 2, reason: 'editorial' }],
|
|
201
|
+
});
|
|
202
|
+
const { init } = lastCall();
|
|
203
|
+
expect(init?.method).toBe('POST');
|
|
204
|
+
expect(JSON.parse(String(init?.body)).boosts[0]).toMatchObject({
|
|
205
|
+
userIds: ['u1', 'u2'],
|
|
206
|
+
weight: 2,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('uses POST when excludeIds are present', async () => {
|
|
211
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 'r1' }]));
|
|
212
|
+
await oxy.getProfileRecommendations({ excludeIds: ['x1', 'x2'] });
|
|
213
|
+
const { init } = lastCall();
|
|
214
|
+
expect(init?.method).toBe('POST');
|
|
215
|
+
expect(JSON.parse(String(init?.body)).excludeIds).toEqual(['x1', 'x2']);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('uses POST when signalWeights are present', async () => {
|
|
219
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 'r1' }]));
|
|
220
|
+
await oxy.getProfileRecommendations({ signalWeights: { graph: 3 } });
|
|
221
|
+
const { init } = lastCall();
|
|
222
|
+
expect(init?.method).toBe('POST');
|
|
223
|
+
expect(JSON.parse(String(init?.body)).signalWeights).toEqual({ graph: 3 });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('uses POST when offset is present', async () => {
|
|
227
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 'r1' }]));
|
|
228
|
+
await oxy.getProfileRecommendations({ offset: 20, limit: 10 });
|
|
229
|
+
const { init } = lastCall();
|
|
230
|
+
expect(init?.method).toBe('POST');
|
|
231
|
+
expect(JSON.parse(String(init?.body))).toMatchObject({ offset: 20, limit: 10 });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('validates the POST body against the contract and rejects bad input', async () => {
|
|
235
|
+
// weight is clamped to [-5, 5] by the schema; 99 must be rejected
|
|
236
|
+
// client-side BEFORE any network call.
|
|
237
|
+
await expect(
|
|
238
|
+
oxy.getProfileRecommendations({ boosts: [{ userIds: ['u1'], weight: 99 }] }),
|
|
239
|
+
).rejects.toThrow();
|
|
240
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('returns the scored items (score/matchedSignals) from the POST path', async () => {
|
|
244
|
+
fetchMock.mockResolvedValueOnce(
|
|
245
|
+
jsonResponse([
|
|
246
|
+
{
|
|
247
|
+
id: 'r1',
|
|
248
|
+
name: { displayName: 'Rec One' },
|
|
249
|
+
mutualCount: 3,
|
|
250
|
+
score: 0.87,
|
|
251
|
+
matchedSignals: ['graph', 'verified'],
|
|
252
|
+
_count: { followers: 10, following: 5 },
|
|
253
|
+
},
|
|
254
|
+
]),
|
|
255
|
+
);
|
|
256
|
+
const result = await oxy.getProfileRecommendations({ clientId: 'app-1' });
|
|
257
|
+
expect(result).toHaveLength(1);
|
|
258
|
+
expect(result[0]).toMatchObject({
|
|
259
|
+
id: 'r1',
|
|
260
|
+
score: 0.87,
|
|
261
|
+
matchedSignals: ['graph', 'verified'],
|
|
262
|
+
mutualCount: 3,
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|