@oxyhq/core 3.6.0 → 3.7.1
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 +78 -14
- 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 +78 -14
- 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/mixins/OxyServices.assets.d.ts +24 -1
- package/dist/types/mixins/OxyServices.user.d.ts +21 -27
- package/dist/types/utils/cacheKey.d.ts +67 -0
- package/dist/types/utils/errorUtils.d.ts +12 -0
- package/package.json +2 -2
- 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/mixins/OxyServices.assets.ts +36 -2
- package/src/mixins/OxyServices.user.ts +104 -32
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -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
|
@@ -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 {
|
|
@@ -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
|
|
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(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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: {
|
|
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({
|
|
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');
|
|
Binary file
|