@oxyhq/core 5.4.1 → 5.4.3

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.
@@ -60,9 +60,22 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
60
60
  _serviceApiKey: string | null;
61
61
  _serviceApiSecret: string | null;
62
62
  /**
63
- * Get profile by username
64
- */
65
- getProfileByUsername(username: string): Promise<User>;
63
+ * Get profile by username.
64
+ *
65
+ * @param username - The profile's username.
66
+ * @param options.cache - Defaults to `true` (5-minute TTL), matching prior
67
+ * behavior. Pass `{ cache: false }` to force a registry-fresh read: the
68
+ * request bypasses BOTH the cache lookup and the post-fetch cache write
69
+ * (see {@link HttpService.request}'s `cache` handling), so it neither
70
+ * serves nor overwrites any entry already cached for this key — a
71
+ * previously cached response (if one exists) is left in place until its
72
+ * own TTL expires or is explicitly invalidated elsewhere. Use this when a
73
+ * caller must observe a just-written change (e.g. a privacy/consent flag)
74
+ * that would otherwise be masked by the TTL window.
75
+ */
76
+ getProfileByUsername(username: string, options?: {
77
+ cache?: boolean;
78
+ }): Promise<User>;
66
79
  /**
67
80
  * Lightweight username lookup for login flows.
68
81
  * Returns minimal public info: exists, color, avatar, name.displayName.
@@ -134,9 +147,22 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
134
147
  */
135
148
  getSimilarProfiles(userId: string, limit?: number): Promise<User[]>;
136
149
  /**
137
- * Get user by ID
138
- */
139
- getUserById(userId: string): Promise<User>;
150
+ * Get user by ID.
151
+ *
152
+ * @param userId - The target user's id.
153
+ * @param options.cache - Defaults to `true` (5-minute TTL), matching prior
154
+ * behavior. Pass `{ cache: false }` to force a registry-fresh read: the
155
+ * request bypasses BOTH the cache lookup and the post-fetch cache write
156
+ * (see {@link HttpService.request}'s `cache` handling), so it neither
157
+ * serves nor overwrites any entry already cached for this key — a
158
+ * previously cached response (if one exists) is left in place until its
159
+ * own TTL expires or is explicitly invalidated elsewhere. Use this when a
160
+ * caller must observe a just-written change (e.g. a privacy/consent flag)
161
+ * that would otherwise be masked by the TTL window.
162
+ */
163
+ getUserById(userId: string, options?: {
164
+ cache?: boolean;
165
+ }): Promise<User>;
140
166
  /**
141
167
  * Fetch many users by id in one round-trip per chunk.
142
168
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "5.4.1",
3
+ "version": "5.4.3",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -94,7 +94,7 @@
94
94
  }
95
95
  },
96
96
  "dependencies": {
97
- "@oxyhq/contracts": "^0.7.0",
97
+ "@oxyhq/contracts": "^0.8.0",
98
98
  "@oxyhq/protocol": "^0.1.1",
99
99
  "bip39": "^3.1.0",
100
100
  "buffer": "^6.0.3",
@@ -101,12 +101,23 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
101
101
  declare _serviceApiSecret: string | null;
102
102
 
103
103
  /**
104
- * Get profile by username
105
- */
106
- async getProfileByUsername(username: string): Promise<User> {
104
+ * Get profile by username.
105
+ *
106
+ * @param username - The profile's username.
107
+ * @param options.cache - Defaults to `true` (5-minute TTL), matching prior
108
+ * behavior. Pass `{ cache: false }` to force a registry-fresh read: the
109
+ * request bypasses BOTH the cache lookup and the post-fetch cache write
110
+ * (see {@link HttpService.request}'s `cache` handling), so it neither
111
+ * serves nor overwrites any entry already cached for this key — a
112
+ * previously cached response (if one exists) is left in place until its
113
+ * own TTL expires or is explicitly invalidated elsewhere. Use this when a
114
+ * caller must observe a just-written change (e.g. a privacy/consent flag)
115
+ * that would otherwise be masked by the TTL window.
116
+ */
117
+ async getProfileByUsername(username: string, options?: { cache?: boolean }): Promise<User> {
107
118
  try {
108
119
  const user = await this.makeRequest<User>('GET', `/profiles/username/${username}`, undefined, {
109
- cache: true,
120
+ cache: options?.cache ?? true,
110
121
  cacheTTL: 5 * 60 * 1000, // 5 minutes cache for profiles
111
122
  });
112
123
  return normalizeUserIdentity(user);
@@ -335,12 +346,23 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
335
346
  }
336
347
 
337
348
  /**
338
- * Get user by ID
339
- */
340
- async getUserById(userId: string): Promise<User> {
349
+ * Get user by ID.
350
+ *
351
+ * @param userId - The target user's id.
352
+ * @param options.cache - Defaults to `true` (5-minute TTL), matching prior
353
+ * behavior. Pass `{ cache: false }` to force a registry-fresh read: the
354
+ * request bypasses BOTH the cache lookup and the post-fetch cache write
355
+ * (see {@link HttpService.request}'s `cache` handling), so it neither
356
+ * serves nor overwrites any entry already cached for this key — a
357
+ * previously cached response (if one exists) is left in place until its
358
+ * own TTL expires or is explicitly invalidated elsewhere. Use this when a
359
+ * caller must observe a just-written change (e.g. a privacy/consent flag)
360
+ * that would otherwise be masked by the TTL window.
361
+ */
362
+ async getUserById(userId: string, options?: { cache?: boolean }): Promise<User> {
341
363
  try {
342
364
  const user = await this.makeRequest<User>('GET', `/users/${userId}`, undefined, {
343
- cache: true,
365
+ cache: options?.cache ?? true,
344
366
  cacheTTL: 5 * 60 * 1000, // 5 minutes cache
345
367
  });
346
368
  return normalizeUserIdentity(user);
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Per-call cache-bypass tests for `getUserById` / `getProfileByUsername`.
3
+ *
4
+ * Both mixin methods cache their GET response for 5 minutes via
5
+ * `HttpService`'s identity-scoped TTL cache. A consumer that just wrote a
6
+ * setting readable through one of these endpoints (e.g. Mention's federation
7
+ * consent flag) needs a way to force a registry-fresh read instead of
8
+ * silently getting served the pre-write snapshot for up to 5 minutes.
9
+ *
10
+ * These tests pin down that:
11
+ * - default behavior is UNCHANGED: a second call within the TTL is served
12
+ * from cache (no second network call),
13
+ * - `{ cache: false }` always hits the network, even immediately after a
14
+ * cached call, and never overwrites the still-live cached entry — a
15
+ * subsequent default-cache call keeps serving the ORIGINAL cached value.
16
+ */
17
+
18
+ import { OxyServices } from '../../OxyServices';
19
+
20
+ /**
21
+ * Build a non-verified JWT whose payload decodes to the given claims.
22
+ * `jwtDecode` only base64url-decodes the middle segment (no signature check).
23
+ */
24
+ function makeJwt(payload: Record<string, unknown>): string {
25
+ const b64url = (obj: Record<string, unknown>): string =>
26
+ Buffer.from(JSON.stringify(obj)).toString('base64url');
27
+ const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
28
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
29
+ }
30
+
31
+ /** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
32
+ function jsonResponse(data: unknown): Response {
33
+ return new Response(JSON.stringify({ data }), {
34
+ status: 200,
35
+ headers: { 'content-type': 'application/json' },
36
+ });
37
+ }
38
+
39
+ describe('getUserById / getProfileByUsername cache bypass', () => {
40
+ let originalFetch: typeof globalThis.fetch;
41
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
42
+ let oxy: OxyServices;
43
+
44
+ beforeEach(() => {
45
+ originalFetch = globalThis.fetch;
46
+ fetchMock = jest.fn();
47
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
48
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
49
+ oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
50
+ });
51
+
52
+ afterEach(() => {
53
+ globalThis.fetch = originalFetch;
54
+ jest.clearAllMocks();
55
+ });
56
+
57
+ describe('getUserById', () => {
58
+ it('default call: a second read within the TTL is served from cache', async () => {
59
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice' }));
60
+ const first = await oxy.getUserById('user-1');
61
+ expect(first.username).toBe('alice');
62
+ expect(fetchMock).toHaveBeenCalledTimes(1);
63
+
64
+ const second = await oxy.getUserById('user-1');
65
+ expect(second.username).toBe('alice');
66
+ expect(fetchMock).toHaveBeenCalledTimes(1);
67
+ });
68
+
69
+ it('{ cache: false } always hits the network, even right after a cached call', async () => {
70
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice' }));
71
+ await oxy.getUserById('user-1');
72
+ expect(fetchMock).toHaveBeenCalledTimes(1);
73
+
74
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice-renamed' }));
75
+ const fresh = await oxy.getUserById('user-1', { cache: false });
76
+ expect(fresh.username).toBe('alice-renamed');
77
+ expect(fetchMock).toHaveBeenCalledTimes(2);
78
+ });
79
+
80
+ it('{ cache: false } does not overwrite the still-live cached entry', async () => {
81
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice' }));
82
+ await oxy.getUserById('user-1');
83
+ expect(fetchMock).toHaveBeenCalledTimes(1);
84
+
85
+ // Bypass read observes server-side truth that has since changed...
86
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', username: 'alice-renamed' }));
87
+ await oxy.getUserById('user-1', { cache: false });
88
+ expect(fetchMock).toHaveBeenCalledTimes(2);
89
+
90
+ // ...but a plain cached read afterward still serves the ORIGINAL cached
91
+ // value — the bypass call never wrote to the cache slot.
92
+ const cached = await oxy.getUserById('user-1');
93
+ expect(cached.username).toBe('alice');
94
+ expect(fetchMock).toHaveBeenCalledTimes(2);
95
+ });
96
+ });
97
+
98
+ describe('getProfileByUsername', () => {
99
+ it('default call: a second read within the TTL is served from cache', async () => {
100
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-2', username: 'bob' }));
101
+ const first = await oxy.getProfileByUsername('bob');
102
+ expect(first.id).toBe('user-2');
103
+ expect(fetchMock).toHaveBeenCalledTimes(1);
104
+
105
+ const second = await oxy.getProfileByUsername('bob');
106
+ expect(second.id).toBe('user-2');
107
+ expect(fetchMock).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it('{ cache: false } always hits the network, even right after a cached call', async () => {
111
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-2', username: 'bob', bio: 'old' }));
112
+ await oxy.getProfileByUsername('bob');
113
+ expect(fetchMock).toHaveBeenCalledTimes(1);
114
+
115
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-2', username: 'bob', bio: 'new' }));
116
+ const fresh = await oxy.getProfileByUsername('bob', { cache: false });
117
+ expect((fresh as unknown as { bio: string }).bio).toBe('new');
118
+ expect(fetchMock).toHaveBeenCalledTimes(2);
119
+ });
120
+ });
121
+ });