@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.
Files changed (35) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +102 -66
  3. package/dist/cjs/mixins/OxyServices.assets.js +34 -2
  4. package/dist/cjs/mixins/OxyServices.user.js +123 -18
  5. package/dist/cjs/utils/cacheKey.js +87 -0
  6. package/dist/cjs/utils/errorUtils.js +25 -0
  7. package/dist/esm/.tsbuildinfo +1 -1
  8. package/dist/esm/HttpService.js +101 -65
  9. package/dist/esm/mixins/OxyServices.assets.js +34 -2
  10. package/dist/esm/mixins/OxyServices.user.js +123 -18
  11. package/dist/esm/utils/cacheKey.js +82 -0
  12. package/dist/esm/utils/errorUtils.js +24 -0
  13. package/dist/types/.tsbuildinfo +1 -1
  14. package/dist/types/HttpService.d.ts +24 -16
  15. package/dist/types/index.d.ts +1 -1
  16. package/dist/types/mixins/OxyServices.assets.d.ts +24 -1
  17. package/dist/types/mixins/OxyServices.user.d.ts +54 -28
  18. package/dist/types/utils/cacheKey.d.ts +67 -0
  19. package/dist/types/utils/errorUtils.d.ts +12 -0
  20. package/package.json +1 -1
  21. package/src/HttpService.ts +116 -67
  22. package/src/__tests__/authManager.cookiePath.test.ts +2 -2
  23. package/src/__tests__/authManager.security.test.ts +2 -2
  24. package/src/__tests__/httpServiceCache.test.ts +71 -0
  25. package/src/index.ts +2 -0
  26. package/src/mixins/OxyServices.assets.ts +36 -2
  27. package/src/mixins/OxyServices.user.ts +167 -36
  28. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
  29. package/src/mixins/__tests__/followCacheInvalidation.test.ts +168 -0
  30. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +83 -0
  31. package/src/mixins/__tests__/sso.test.ts +13 -3
  32. package/src/utils/__tests__/cacheKey.test.ts +0 -0
  33. package/src/utils/__tests__/coldBoot.test.ts +125 -0
  34. package/src/utils/cacheKey.ts +98 -0
  35. 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
@@ -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
 
@@ -19,15 +19,49 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
19
19
  }
20
20
 
21
21
  /**
22
- * Get file download URL (synchronous - uses stream endpoint for images to avoid ORB blocking)
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 { UserNameResponse, UserProfileUpdate } from '@oxyhq/contracts';
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, optionally filtering out specific user types.
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(options?: {
168
- excludeTypes?: Array<'federated' | 'agent' | 'automated'>;
169
- }): Promise<Array<{
170
- id: string;
171
- username: string;
172
- name: UserNameResponse;
173
- description?: string;
174
- isFederated?: boolean;
175
- isAgent?: boolean;
176
- isAutomated?: boolean;
177
- instance?: string;
178
- federation?: { actorUri?: string; domain?: string; actorId?: string };
179
- automation?: { ownerId?: string };
180
- _count?: { followers: number; following: number };
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
- const params = options?.excludeTypes?.length
185
- ? { excludeTypes: options.excludeTypes.join(',') }
186
- : undefined;
187
- return await this.makeRequest('GET', '/profiles/recommendations', params, { cache: true });
188
- } catch (error) {
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 errorRecord = error && typeof error === 'object'
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
- return await this.makeRequest('POST', `/users/${userId}/follow`, undefined, { cache: false });
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
- return await this.makeRequest<BulkFollowResult>('POST', '/users/follow/bulk', { userIds }, { cache: false });
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
- return await this.makeRequest('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
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
+ });