@oxyhq/core 3.6.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 (32) 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 +78 -14
  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 +78 -14
  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/mixins/OxyServices.assets.d.ts +24 -1
  16. package/dist/types/mixins/OxyServices.user.d.ts +21 -27
  17. package/dist/types/utils/cacheKey.d.ts +67 -0
  18. package/dist/types/utils/errorUtils.d.ts +12 -0
  19. package/package.json +1 -1
  20. package/src/HttpService.ts +116 -67
  21. package/src/__tests__/authManager.cookiePath.test.ts +2 -2
  22. package/src/__tests__/authManager.security.test.ts +2 -2
  23. package/src/__tests__/httpServiceCache.test.ts +71 -0
  24. package/src/mixins/OxyServices.assets.ts +36 -2
  25. package/src/mixins/OxyServices.user.ts +104 -32
  26. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
  27. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +83 -0
  28. package/src/mixins/__tests__/sso.test.ts +13 -3
  29. package/src/utils/__tests__/cacheKey.test.ts +0 -0
  30. package/src/utils/__tests__/coldBoot.test.ts +125 -0
  31. package/src/utils/cacheKey.ts +98 -0
  32. package/src/utils/errorUtils.ts +25 -0
@@ -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 {
@@ -149,7 +157,26 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
149
157
  cacheTTL: 24 * 60 * 60 * 1000, // 24h cache — matches server-side staleness window
150
158
  });
151
159
  return normalizeUserIdentityOrNull(result);
152
- } 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
+ );
153
180
  return null;
154
181
  }
155
182
  }
@@ -173,7 +200,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
173
200
  }
174
201
 
175
202
  /**
176
- * Get profile recommendations, optionally filtering out specific user types.
203
+ * Get profile recommendations.
177
204
  *
178
205
  * Public discovery read — works WITHOUT authentication. The SDK attaches the
179
206
  * access token automatically when one is available (personalized via
@@ -181,29 +208,81 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
181
208
  * the caller is logged out. This deliberately does NOT use `withAuthRetry`,
182
209
  * which would throw an authentication timeout for logged-out callers before
183
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.
184
229
  */
185
- async getProfileRecommendations(options?: {
186
- excludeTypes?: Array<'federated' | 'agent' | 'automated'>;
187
- }): Promise<Array<{
188
- id: string;
189
- username: string;
190
- name: UserNameResponse;
191
- description?: string;
192
- isFederated?: boolean;
193
- isAgent?: boolean;
194
- isAutomated?: boolean;
195
- instance?: string;
196
- federation?: { actorUri?: string; domain?: string; actorId?: string };
197
- automation?: { ownerId?: string };
198
- _count?: { followers: number; following: number };
199
- [key: string]: unknown;
200
- }>> {
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
+
201
244
  try {
202
- const params = options?.excludeTypes?.length
203
- ? { excludeTypes: options.excludeTypes.join(',') }
204
- : undefined;
205
- return await this.makeRequest('GET', '/profiles/recommendations', params, { cache: true });
206
- } 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
+ });
207
286
  throw this.handleError(error);
208
287
  }
209
288
  }
@@ -281,14 +360,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
281
360
  return result;
282
361
  } catch (error) {
283
362
  const errorMessage = error instanceof Error ? error.message : String(error);
284
- const errorRecord = error && typeof error === 'object'
285
- ? error as { status?: unknown; response?: { status?: unknown } }
286
- : null;
287
- const status = typeof errorRecord?.status === 'number'
288
- ? errorRecord.status
289
- : typeof errorRecord?.response?.status === 'number'
290
- ? errorRecord.response.status
291
- : undefined;
363
+ const status = extractErrorStatus(error);
292
364
 
293
365
  // Check if it's an authentication error (401)
294
366
  const isAuthError = status === 401 ||
@@ -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
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `OxyServices.getFileDownloadUrl()` resolution tests.
3
+ *
4
+ * This is the single chokepoint every Oxy app uses to turn a stored asset id
5
+ * into a `<img src>`-ready URL. It resolves to one of two forms:
6
+ *
7
+ * - PUBLIC (no access token planted, no `expiresIn`) → the clean CDN form
8
+ * `${cloudURL}/<id>[?variant=...]` (default `https://cloud.oxy.so/<id>`),
9
+ * which CloudFront resolves against the public media origin.
10
+ * - SIGNED / PRIVATE (an access token is present OR `expiresIn` is passed) →
11
+ * the authenticated API origin form
12
+ * `${baseURL}/assets/<id>/stream?...&token=...` — private assets are not on
13
+ * the public CDN.
14
+ */
15
+
16
+ import { OxyServices } from '../../OxyServices';
17
+
18
+ describe('OxyServices.getFileDownloadUrl', () => {
19
+ describe('public assets (no token, no expiresIn) → CDN', () => {
20
+ it('returns the clean cloud.oxy.so URL for a bare file id', () => {
21
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
22
+
23
+ expect(oxy.getFileDownloadUrl('file123')).toBe('https://cloud.oxy.so/file123');
24
+ });
25
+
26
+ it('appends only a variant query param (no token/fallback) for the thumb variant', () => {
27
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
28
+
29
+ expect(oxy.getFileDownloadUrl('file123', 'thumb')).toBe(
30
+ 'https://cloud.oxy.so/file123?variant=thumb',
31
+ );
32
+ });
33
+
34
+ it('uses the configured cloudURL when overridden', () => {
35
+ const oxy = new OxyServices({
36
+ baseURL: 'https://api.oxy.so',
37
+ cloudURL: 'https://cdn.example.test',
38
+ });
39
+
40
+ expect(oxy.getFileDownloadUrl('file123', 'thumb')).toBe(
41
+ 'https://cdn.example.test/file123?variant=thumb',
42
+ );
43
+ });
44
+
45
+ it('URL-encodes the file id and the variant', () => {
46
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
47
+
48
+ expect(oxy.getFileDownloadUrl('a/b c', 'large size')).toBe(
49
+ 'https://cloud.oxy.so/a%2Fb%20c?variant=large%20size',
50
+ );
51
+ });
52
+ });
53
+
54
+ describe('signed / private assets → authenticated API origin', () => {
55
+ it('returns the stream endpoint with the token when an access token is present', () => {
56
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
57
+ oxy.setTokens('access-token-abc');
58
+
59
+ const url = oxy.getFileDownloadUrl('file123', 'thumb');
60
+
61
+ expect(url.startsWith('https://api.oxy.so/assets/file123/stream?')).toBe(true);
62
+ const params = new URLSearchParams(url.split('?')[1]);
63
+ expect(params.get('variant')).toBe('thumb');
64
+ expect(params.get('token')).toBe('access-token-abc');
65
+ expect(params.get('fallback')).toBe('placeholderVisible');
66
+ expect(url).not.toContain('cloud.oxy.so');
67
+ });
68
+
69
+ it('routes through the stream endpoint when expiresIn is requested even without a token', () => {
70
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
71
+
72
+ const url = oxy.getFileDownloadUrl('file123', 'thumb', 3600);
73
+
74
+ expect(url.startsWith('https://api.oxy.so/assets/file123/stream?')).toBe(true);
75
+ const params = new URLSearchParams(url.split('?')[1]);
76
+ expect(params.get('expiresIn')).toBe('3600');
77
+ expect(params.get('variant')).toBe('thumb');
78
+ expect(params.get('fallback')).toBe('placeholderVisible');
79
+ expect(params.get('token')).toBeNull();
80
+ expect(url).not.toContain('cloud.oxy.so');
81
+ });
82
+ });
83
+ });
@@ -36,7 +36,12 @@ const VALID_BODY = {
36
36
  sessionId: 'sess_sso',
37
37
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
38
38
  authuser: 0,
39
- user: { id: 'user_sso', username: 'ssouser', avatar: 'file_1' },
39
+ user: {
40
+ id: 'user_sso',
41
+ username: 'ssouser',
42
+ name: { displayName: 'SSO User', first: 'SSO', last: 'User', full: 'SSO User' },
43
+ avatar: 'file_1',
44
+ },
40
45
  };
41
46
 
42
47
  describe('OxyServices.exchangeSsoCode', () => {
@@ -83,7 +88,12 @@ describe('OxyServices.exchangeSsoCode', () => {
83
88
  expect(oxy.getAccessToken()).toBe('access_sso');
84
89
  expect(session.sessionId).toBe('sess_sso');
85
90
  expect(session.accessToken).toBe('access_sso');
86
- expect(session.user).toEqual({ id: 'user_sso', username: 'ssouser', avatar: 'file_1' });
91
+ expect(session.user).toEqual({
92
+ id: 'user_sso',
93
+ username: 'ssouser',
94
+ name: { displayName: 'SSO User', first: 'SSO', last: 'User', full: 'SSO User' },
95
+ avatar: 'file_1',
96
+ });
87
97
  expect(session.expiresAt).toBe(VALID_BODY.expiresAt);
88
98
  });
89
99
 
@@ -92,7 +102,7 @@ describe('OxyServices.exchangeSsoCode', () => {
92
102
  mockFetchOnce({
93
103
  accessToken: 'access_sso',
94
104
  sessionId: 'sess_sso',
95
- user: { _id: 'mongo_id', username: 'ssouser' },
105
+ user: { _id: 'mongo_id', username: 'ssouser', name: { displayName: 'SSO User' } },
96
106
  });
97
107
 
98
108
  const session = await oxy.exchangeSsoCode('opaque-code-123');