@oxyhq/core 12.11.1 → 13.2.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 (56) hide show
  1. package/README.md +36 -2
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +4 -13
  4. package/dist/cjs/mixins/OxyServices.deviceBoot.js +0 -31
  5. package/dist/cjs/mixins/OxyServices.user.js +50 -44
  6. package/dist/cjs/server/index.js +9 -6
  7. package/dist/cjs/server/rateLimit.js +3 -0
  8. package/dist/cjs/server/securityHeaders.js +234 -0
  9. package/dist/cjs/session/accountDialogController.js +6 -8
  10. package/dist/cjs/utils/apiUtils.js +40 -10
  11. package/dist/cjs/utils/oauthPkce.js +1 -5
  12. package/dist/cjs/utils/officialOrigins.js +3 -73
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/index.js +3 -4
  15. package/dist/esm/mixins/OxyServices.deviceBoot.js +1 -32
  16. package/dist/esm/mixins/OxyServices.user.js +51 -45
  17. package/dist/esm/server/index.js +4 -1
  18. package/dist/esm/server/rateLimit.js +3 -0
  19. package/dist/esm/server/securityHeaders.js +224 -0
  20. package/dist/esm/session/accountDialogController.js +6 -8
  21. package/dist/esm/utils/apiUtils.js +39 -10
  22. package/dist/esm/utils/oauthPkce.js +0 -4
  23. package/dist/esm/utils/officialOrigins.js +3 -68
  24. package/dist/types/.tsbuildinfo +1 -1
  25. package/dist/types/index.d.ts +5 -7
  26. package/dist/types/mixins/OxyServices.auth.d.ts +1 -12
  27. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +1 -5
  28. package/dist/types/mixins/OxyServices.user.d.ts +27 -6
  29. package/dist/types/server/index.d.ts +3 -1
  30. package/dist/types/server/securityHeaders.d.ts +154 -0
  31. package/dist/types/session/accountDialogController.d.ts +9 -15
  32. package/dist/types/utils/apiUtils.d.ts +48 -6
  33. package/dist/types/utils/oauthPkce.d.ts +11 -7
  34. package/dist/types/utils/officialOrigins.d.ts +3 -13
  35. package/package.json +10 -5
  36. package/src/index.ts +9 -14
  37. package/src/mixins/OxyServices.auth.ts +6 -13
  38. package/src/mixins/OxyServices.deviceBoot.ts +0 -47
  39. package/src/mixins/OxyServices.user.ts +60 -49
  40. package/src/mixins/__tests__/commonsSignIn.test.ts +9 -2
  41. package/src/mixins/__tests__/followGraphPagination.test.ts +250 -0
  42. package/src/server/__tests__/securityHeaders.test.ts +244 -0
  43. package/src/server/index.ts +17 -8
  44. package/src/server/rateLimit.ts +3 -0
  45. package/src/server/securityHeaders.ts +304 -0
  46. package/src/session/__tests__/accountDialogController.test.ts +3 -5
  47. package/src/session/accountDialogController.ts +12 -18
  48. package/src/utils/__tests__/officialOrigins.test.ts +0 -57
  49. package/src/utils/apiUtils.ts +64 -15
  50. package/src/utils/oauthPkce.ts +11 -9
  51. package/src/utils/officialOrigins.ts +3 -70
  52. package/dist/cjs/session/hubSync.js +0 -55
  53. package/dist/esm/session/hubSync.js +0 -51
  54. package/dist/types/session/hubSync.d.ts +0 -20
  55. package/src/session/__tests__/hubSync.test.ts +0 -51
  56. package/src/session/hubSync.ts +0 -79
@@ -15,12 +15,8 @@
15
15
  */
16
16
  import {
17
17
  deviceTokenMintResponseSchema,
18
- deviceHubTicketIssueResponseSchema,
19
- deviceHubTicketRedeemResponseSchema,
20
18
  safeParseContract,
21
19
  type DeviceTokenMintResponse,
22
- type DeviceHubTicketIssueResponse,
23
- type DeviceHubTicketRedeemResponse,
24
20
  } from '@oxyhq/contracts';
25
21
  import type { OxyServicesBase } from '../OxyServices.base';
26
22
 
@@ -124,48 +120,5 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
124
120
  throw normalized;
125
121
  }
126
122
  }
127
-
128
- /** Mint a one-time hub sync ticket (bearer required). */
129
- async issueHubTicket(returnOrigin: string): Promise<DeviceHubTicketIssueResponse> {
130
- try {
131
- const res = await this.makeRequest<unknown>(
132
- 'POST',
133
- '/session/device/hub-ticket',
134
- { returnOrigin },
135
- { cache: false },
136
- );
137
- const parsed = safeParseContract(deviceHubTicketIssueResponseSchema, res);
138
- if (!parsed) {
139
- throw new Error('session/device/hub-ticket returned an unexpected response shape');
140
- }
141
- return parsed;
142
- } catch (error) {
143
- throw this.handleError(error);
144
- }
145
- }
146
-
147
- /** Redeem a hub sync ticket for a fresh device secret (public). */
148
- async redeemHubTicket(
149
- ticket: string,
150
- returnOrigin: string,
151
- ): Promise<DeviceHubTicketRedeemResponse> {
152
- try {
153
- const res = await this.makeRequest<unknown>(
154
- 'POST',
155
- '/session/device/redeem-ticket',
156
- { ticket, returnOrigin },
157
- // Public device-hub sync mint (bearer-less). Same control-plane class as
158
- // the device-secret mint — bypassQueue so it never waits for a slot.
159
- { cache: false, skipAuth: true, bypassQueue: true },
160
- );
161
- const parsed = safeParseContract(deviceHubTicketRedeemResponseSchema, res);
162
- if (!parsed) {
163
- throw new Error('session/device/redeem-ticket returned an unexpected response shape');
164
- }
165
- return parsed;
166
- } catch (error) {
167
- throw this.handleError(error);
168
- }
169
- }
170
123
  };
171
124
  }
@@ -19,7 +19,12 @@ import type {
19
19
  } from '@oxyhq/contracts';
20
20
  import { recommendationRequestSchema } from '@oxyhq/contracts';
21
21
  import type { OxyServicesBase } from '../OxyServices.base';
22
- import { buildSearchParams, buildPaginationParams, type PaginationParams } from '../utils/apiUtils';
22
+ import {
23
+ buildQueryParams,
24
+ buildPaginationParams,
25
+ type PaginationParams,
26
+ type FollowGraphParams,
27
+ } from '../utils/apiUtils';
23
28
  import { KeyManager } from '../crypto/keyManager';
24
29
  import { SignatureService } from '../crypto/signatureService';
25
30
  import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
@@ -198,14 +203,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
198
203
  */
199
204
  async searchProfiles(query: string, pagination?: PaginationParams): Promise<SearchProfilesResponse> {
200
205
  try {
201
- const params = { query, ...pagination };
202
- const searchParams = buildSearchParams(params);
203
- const paramsObj = Object.fromEntries(searchParams.entries());
204
-
205
206
  const response = await this.makeRequest<SearchProfilesResponse>(
206
207
  'GET',
207
208
  '/profiles/search',
208
- paramsObj,
209
+ buildQueryParams({ query, ...pagination }),
209
210
  {
210
211
  cache: true,
211
212
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
@@ -727,6 +728,42 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
727
728
  }
728
729
 
729
730
 
731
+ /**
732
+ * Invalidate every cached read a follow/unfollow write invalidates.
733
+ *
734
+ * Shared by the four mutation entry points (`followUser`, `unfollowUser`,
735
+ * `followUsers`, `unfollowUsers`) so they can never drift on which caches a
736
+ * write busts.
737
+ *
738
+ * The follower/following/mutuals LISTS are cleared by PREFIX rather than by
739
+ * exact key. Those reads are paginated and ordered, so one logical list is
740
+ * spread across many content-addressed keys
741
+ * (`GET:/users/<id>/followers:{"limit":"20","offset":"40","sort":"oldest"}`);
742
+ * an exact-key clear would only bust whichever page/sort variant happened to
743
+ * be read last and would leave every other page stale. `clearCacheByPrefix`
744
+ * deletes all of them, and all identity-scoped variants of each.
745
+ */
746
+ invalidateFollowGraphCaches(targetUserIds: string[]): void {
747
+ for (const id of targetUserIds) {
748
+ this.clearCacheEntry(`GET:/users/${id}/follow-status`);
749
+ // Profile fetches embed viewer-relative `relationship` — bust so a
750
+ // remount doesn't serve a stale isFollowing for up to 5 minutes.
751
+ this.clearCacheEntry(`GET:/users/${id}`);
752
+ // The target gained/lost a follower, and the viewer's presence in the
753
+ // target's "followers you know" set changed with it.
754
+ this.clearCacheByPrefix(`GET:/users/${id}/followers`);
755
+ this.clearCacheByPrefix(`GET:/users/${id}/mutuals`);
756
+ }
757
+ this.clearCacheByPrefix('GET:/profiles/username/');
758
+ this.clearCacheByPrefix('GET:/profiles/resolve');
759
+ // The write changed the viewer's OWN following list and graph.
760
+ const viewerId = this.getCurrentUserId();
761
+ if (viewerId) {
762
+ this.clearCacheByPrefix(`GET:/users/${viewerId}/following`);
763
+ }
764
+ this.clearCacheEntry('GET:/users/me/graph');
765
+ }
766
+
730
767
  /**
731
768
  * Follow a user.
732
769
  *
@@ -740,16 +777,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
740
777
  async followUser(userId: string): Promise<FollowMutationResult> {
741
778
  try {
742
779
  const result = await this.makeRequest<FollowMutationResult>('POST', `/users/${userId}/follow`, undefined, { cache: false });
743
- this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
744
- // Profile fetches embed viewer-relative `relationship` — bust so a
745
- // remount doesn't serve a stale isFollowing for up to 5 minutes.
746
- this.clearCacheEntry(`GET:/users/${userId}`);
747
- this.clearCacheByPrefix('GET:/profiles/username/');
748
- this.clearCacheByPrefix('GET:/profiles/resolve');
749
- // The follow changed the viewer's graph — bust the cached consolidated
750
- // `GET /users/me/graph` so the next read reflects the new following/
751
- // mutual set instead of the stale pre-write snapshot.
752
- this.clearCacheEntry('GET:/users/me/graph');
780
+ this.invalidateFollowGraphCaches([userId]);
753
781
  return result;
754
782
  } catch (error) {
755
783
  throw this.handleError(error);
@@ -770,15 +798,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
770
798
  }
771
799
  try {
772
800
  const result = await this.makeRequest<BulkFollowResult>('POST', '/users/follow/bulk', { userIds }, { cache: false });
773
- // Bust each affected user's cached follow-status (see `followUser`).
774
- for (const id of userIds) {
775
- this.clearCacheEntry(`GET:/users/${id}/follow-status`);
776
- this.clearCacheEntry(`GET:/users/${id}`);
777
- }
778
- this.clearCacheByPrefix('GET:/profiles/username/');
779
- this.clearCacheByPrefix('GET:/profiles/resolve');
780
- // The batch changed the viewer's graph — bust the consolidated cache.
781
- this.clearCacheEntry('GET:/users/me/graph');
801
+ this.invalidateFollowGraphCaches(userIds);
782
802
  return result;
783
803
  } catch (error) {
784
804
  throw this.handleError(error);
@@ -799,15 +819,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
799
819
  }
800
820
  try {
801
821
  const result = await this.makeRequest<BulkUnfollowResult>('POST', '/users/unfollow/bulk', { userIds }, { cache: false });
802
- // Bust each affected user's cached follow-status (see `followUser`).
803
- for (const id of userIds) {
804
- this.clearCacheEntry(`GET:/users/${id}/follow-status`);
805
- this.clearCacheEntry(`GET:/users/${id}`);
806
- }
807
- this.clearCacheByPrefix('GET:/profiles/username/');
808
- this.clearCacheByPrefix('GET:/profiles/resolve');
809
- // The batch changed the viewer's graph — bust the consolidated cache.
810
- this.clearCacheEntry('GET:/users/me/graph');
822
+ this.invalidateFollowGraphCaches(userIds);
811
823
  return result;
812
824
  } catch (error) {
813
825
  throw this.handleError(error);
@@ -820,13 +832,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
820
832
  async unfollowUser(userId: string): Promise<FollowMutationResult> {
821
833
  try {
822
834
  const result = await this.makeRequest<FollowMutationResult>('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
823
- // Bust the cached follow-status so a remount reads fresh truth (see `followUser`).
824
- this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
825
- this.clearCacheEntry(`GET:/users/${userId}`);
826
- this.clearCacheByPrefix('GET:/profiles/username/');
827
- this.clearCacheByPrefix('GET:/profiles/resolve');
828
- // The unfollow changed the viewer's graph — bust the consolidated cache.
829
- this.clearCacheEntry('GET:/users/me/graph');
835
+ this.invalidateFollowGraphCaches([userId]);
830
836
  return result;
831
837
  } catch (error) {
832
838
  throw this.handleError(error);
@@ -899,14 +905,19 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
899
905
  }
900
906
 
901
907
  /**
902
- * Get user followers
908
+ * Get user followers.
909
+ *
910
+ * `sort` orders the underlying follow edges — `recent` (newest first, the
911
+ * server default) or `oldest`. Because the response is cached and the cache
912
+ * key is content-addressed on the query params, each `limit`/`offset`/`sort`
913
+ * combination is its own entry.
903
914
  */
904
915
  async getUserFollowers(
905
916
  userId: string,
906
- pagination?: PaginationParams
917
+ pagination?: FollowGraphParams
907
918
  ): Promise<{ followers: User[]; total: number; hasMore: boolean }> {
908
919
  try {
909
- const params = buildPaginationParams(pagination || {});
920
+ const params = buildQueryParams(pagination || {});
910
921
  const response = await this.makeRequest<{ data: User[]; pagination: { total: number; hasMore: boolean } }>('GET', `/users/${userId}/followers`, params, {
911
922
  cache: true,
912
923
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
@@ -922,14 +933,14 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
922
933
  }
923
934
 
924
935
  /**
925
- * Get user following
936
+ * Get user following. `sort` behaves as in {@link getUserFollowers}.
926
937
  */
927
938
  async getUserFollowing(
928
939
  userId: string,
929
- pagination?: PaginationParams
940
+ pagination?: FollowGraphParams
930
941
  ): Promise<{ following: User[]; total: number; hasMore: boolean }> {
931
942
  try {
932
- const params = buildPaginationParams(pagination || {});
943
+ const params = buildQueryParams(pagination || {});
933
944
  const response = await this.makeRequest<{ data: User[]; pagination: { total: number; hasMore: boolean } }>('GET', `/users/${userId}/following`, params, {
934
945
  cache: true,
935
946
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
@@ -950,10 +961,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
950
961
  */
951
962
  async getUserMutuals(
952
963
  userId: string,
953
- pagination?: PaginationParams
964
+ pagination?: FollowGraphParams
954
965
  ): Promise<{ mutuals: User[]; total: number; hasMore: boolean }> {
955
966
  try {
956
- const params = buildPaginationParams(pagination || {});
967
+ const params = buildQueryParams(pagination || {});
957
968
  const response = await this.makeRequest<{ data: User[]; pagination: { total: number; hasMore: boolean } }>('GET', `/users/${userId}/mutuals`, params, {
958
969
  cache: true,
959
970
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
@@ -12,6 +12,8 @@
12
12
  * degrade-to-QR behaviour every ambiguous input must produce.
13
13
  */
14
14
 
15
+ import type { CommonsDenyReason } from '@oxyhq/contracts';
16
+ import { COMMONS_DENY_REASONS } from '@oxyhq/contracts';
15
17
  import type { SessionLoginResponse } from '../../models/session';
16
18
  import type { ChallengeResponse } from '../OxyServices.auth';
17
19
  import type { CommonsDeliveryPlatform } from '../../utils/commonsDelivery';
@@ -769,12 +771,17 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
769
771
  );
770
772
  });
771
773
 
772
- it.each([['declined'], ['not_me']] as const)(
774
+ // Iterating the CONTRACT set (rather than a local literal) is the point: the
775
+ // SDK, the API request schema and the persisted `AuthSession.deniedReason`
776
+ // enum all read this one declaration, so a value added on the server side
777
+ // without an SDK release — or the reverse — cannot go unnoticed here.
778
+ it.each([...COMMONS_DENY_REASONS])(
773
779
  'sends the closed-set reason %s in the body',
774
780
  async (reason) => {
775
781
  makeRequestSpy.mockResolvedValue({ success: true });
776
782
 
777
- await oxy.denyCommonsSignIn('code-1', reason);
783
+ const typedReason: CommonsDenyReason = reason;
784
+ await oxy.denyCommonsSignIn('code-1', typedReason);
778
785
 
779
786
  expect(makeRequestSpy).toHaveBeenCalledWith(
780
787
  'POST',
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Follow-graph pagination + ordering tests.
3
+ *
4
+ * Regression coverage for the silent "every page is page one" bug.
5
+ * `buildPaginationParams` used to return a `URLSearchParams`, which it then
6
+ * handed to `makeRequest` as a GET's `params`. `HttpService` reads that object
7
+ * with `Object.keys(...)` in TWO places — `buildURL` (decide whether to append
8
+ * a query string) and `generateBaseCacheKey` (build the cache key) — and
9
+ * `Object.keys(new URLSearchParams({ limit: '20' }))` is `[]`, because a
10
+ * `URLSearchParams` exposes its entries through iterator methods rather than
11
+ * own enumerable properties. The consequences were both invisible from the
12
+ * call site:
13
+ *
14
+ * - no query string was ever sent, so every caller silently got the server's
15
+ * DEFAULT page no matter which `limit`/`offset` it asked for, and
16
+ * - every page collapsed onto ONE cache key, so page 2 was served page 1's
17
+ * cached body without a network call.
18
+ *
19
+ * These tests assert on the URL `fetch` actually received (not on the helper's
20
+ * return value), so they fail against the old `URLSearchParams` implementation
21
+ * and cannot pass vacuously.
22
+ */
23
+
24
+ import { OxyServices } from '../../OxyServices';
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
+ */
30
+ function makeJwt(payload: Record<string, unknown>): string {
31
+ const b64url = (obj: Record<string, unknown>): string =>
32
+ Buffer.from(JSON.stringify(obj)).toString('base64url');
33
+ const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
34
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
35
+ }
36
+
37
+ /** A paginated `{ data, pagination }` body — passed through by `unwrapResponse`. */
38
+ function pageResponse(data: unknown[], total = 100, hasMore = true): Response {
39
+ return new Response(JSON.stringify({ data, pagination: { total, hasMore } }), {
40
+ status: 200,
41
+ headers: { 'content-type': 'application/json' },
42
+ });
43
+ }
44
+
45
+ /** A JSON `{ data: ... }` success envelope. */
46
+ function jsonResponse(data: unknown): Response {
47
+ return new Response(JSON.stringify({ data }), {
48
+ status: 200,
49
+ headers: { 'content-type': 'application/json' },
50
+ });
51
+ }
52
+
53
+ describe('follow-graph pagination and ordering', () => {
54
+ let originalFetch: typeof globalThis.fetch;
55
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
56
+ let oxy: OxyServices;
57
+
58
+ /** The URL string passed to `fetch` on call `n` (0-based). */
59
+ const requestedUrl = (n: number): string => String(fetchMock.mock.calls[n][0]);
60
+
61
+ beforeEach(() => {
62
+ originalFetch = globalThis.fetch;
63
+ fetchMock = jest.fn();
64
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
65
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
66
+ oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
67
+ });
68
+
69
+ afterEach(() => {
70
+ globalThis.fetch = originalFetch;
71
+ jest.clearAllMocks();
72
+ });
73
+
74
+ describe('the request actually carries the pagination the caller asked for', () => {
75
+ it('sends limit and offset on getUserFollowers', async () => {
76
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
77
+ await oxy.getUserFollowers('target-1', { limit: 20, offset: 40 });
78
+
79
+ const url = new URL(requestedUrl(0));
80
+ expect(url.pathname).toBe('/users/target-1/followers');
81
+ expect(url.searchParams.get('limit')).toBe('20');
82
+ expect(url.searchParams.get('offset')).toBe('40');
83
+ });
84
+
85
+ it('sends limit and offset on getUserFollowing', async () => {
86
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
87
+ await oxy.getUserFollowing('target-1', { limit: 5, offset: 10 });
88
+
89
+ const url = new URL(requestedUrl(0));
90
+ expect(url.pathname).toBe('/users/target-1/following');
91
+ expect(url.searchParams.get('limit')).toBe('5');
92
+ expect(url.searchParams.get('offset')).toBe('10');
93
+ });
94
+
95
+ it('sends limit and offset on getUserMutuals', async () => {
96
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
97
+ await oxy.getUserMutuals('target-1', { limit: 7, offset: 14 });
98
+
99
+ const url = new URL(requestedUrl(0));
100
+ expect(url.pathname).toBe('/users/target-1/mutuals');
101
+ expect(url.searchParams.get('limit')).toBe('7');
102
+ expect(url.searchParams.get('offset')).toBe('14');
103
+ });
104
+
105
+ it('sends limit on the id-only graph seeds', async () => {
106
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
107
+ await oxy.getMutualUserIds({ limit: 33 });
108
+ expect(new URL(requestedUrl(0)).searchParams.get('limit')).toBe('33');
109
+
110
+ fetchMock.mockResolvedValueOnce(jsonResponse([]));
111
+ await oxy.getFollowsOfFollowsIds({ limit: 44 });
112
+ expect(new URL(requestedUrl(1)).searchParams.get('limit')).toBe('44');
113
+ });
114
+
115
+ it('omits the query string entirely when no pagination is given', async () => {
116
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
117
+ await oxy.getUserFollowers('target-1');
118
+
119
+ expect(requestedUrl(0)).toBe('http://test.invalid/users/target-1/followers');
120
+ });
121
+ });
122
+
123
+ describe('each page is its own cache entry', () => {
124
+ it('does NOT serve page 1 cached body to a page 2 request', async () => {
125
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'a' }]));
126
+ const first = await oxy.getUserFollowers('target-1', { limit: 1, offset: 0 });
127
+ expect(first.followers).toEqual([{ id: 'a' }]);
128
+ expect(fetchMock).toHaveBeenCalledTimes(1);
129
+
130
+ // Different offset ⇒ different cache key ⇒ a real second network call.
131
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'b' }]));
132
+ const second = await oxy.getUserFollowers('target-1', { limit: 1, offset: 1 });
133
+
134
+ expect(fetchMock).toHaveBeenCalledTimes(2);
135
+ expect(second.followers).toEqual([{ id: 'b' }]);
136
+ expect(new URL(requestedUrl(1)).searchParams.get('offset')).toBe('1');
137
+ });
138
+
139
+ it('still serves a warm cache hit for the SAME page', async () => {
140
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'a' }]));
141
+ await oxy.getUserFollowers('target-1', { limit: 1, offset: 0 });
142
+ await oxy.getUserFollowers('target-1', { limit: 1, offset: 0 });
143
+
144
+ expect(fetchMock).toHaveBeenCalledTimes(1);
145
+ });
146
+ });
147
+
148
+ describe('sort', () => {
149
+ it('sends sort=oldest and keeps it out of the request when unset', async () => {
150
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
151
+ await oxy.getUserFollowers('target-1', { limit: 10, sort: 'oldest' });
152
+ expect(new URL(requestedUrl(0)).searchParams.get('sort')).toBe('oldest');
153
+
154
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
155
+ await oxy.getUserFollowers('target-1', { limit: 10 });
156
+ expect(new URL(requestedUrl(1)).searchParams.has('sort')).toBe(false);
157
+ });
158
+
159
+ it('discriminates the cache key, so flipping sort re-fetches', async () => {
160
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'newest' }]));
161
+ const recent = await oxy.getUserFollowers('target-1', { limit: 2, offset: 0, sort: 'recent' });
162
+ expect(recent.followers).toEqual([{ id: 'newest' }]);
163
+ expect(fetchMock).toHaveBeenCalledTimes(1);
164
+
165
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'oldest' }]));
166
+ const oldest = await oxy.getUserFollowers('target-1', { limit: 2, offset: 0, sort: 'oldest' });
167
+
168
+ expect(fetchMock).toHaveBeenCalledTimes(2);
169
+ expect(oldest.followers).toEqual([{ id: 'oldest' }]);
170
+ });
171
+
172
+ it('threads sort through getUserFollowing and getUserMutuals', async () => {
173
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
174
+ await oxy.getUserFollowing('target-1', { sort: 'oldest' });
175
+ expect(new URL(requestedUrl(0)).searchParams.get('sort')).toBe('oldest');
176
+
177
+ fetchMock.mockResolvedValueOnce(pageResponse([]));
178
+ await oxy.getUserMutuals('target-1', { sort: 'oldest' });
179
+ expect(new URL(requestedUrl(1)).searchParams.get('sort')).toBe('oldest');
180
+ });
181
+ });
182
+
183
+ describe('follow writes invalidate the cached follower/following lists', () => {
184
+ it('re-fetches the followers list after followUser', async () => {
185
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'a' }], 1, false));
186
+ await oxy.getUserFollowers('target-1', { limit: 10, offset: 0 });
187
+ expect(fetchMock).toHaveBeenCalledTimes(1);
188
+
189
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
190
+ await oxy.followUser('target-1');
191
+ expect(fetchMock).toHaveBeenCalledTimes(2);
192
+
193
+ // The viewer is now a follower — the list must not come from cache.
194
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'a' }, { id: 'me' }], 2, false));
195
+ const after = await oxy.getUserFollowers('target-1', { limit: 10, offset: 0 });
196
+
197
+ expect(fetchMock).toHaveBeenCalledTimes(3);
198
+ expect(after.followers).toHaveLength(2);
199
+ });
200
+
201
+ it('re-fetches the viewer own following list after followUser', async () => {
202
+ fetchMock.mockResolvedValueOnce(pageResponse([], 0, false));
203
+ await oxy.getUserFollowing('me', { limit: 10, offset: 0 });
204
+ expect(fetchMock).toHaveBeenCalledTimes(1);
205
+
206
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
207
+ await oxy.followUser('target-1');
208
+
209
+ fetchMock.mockResolvedValueOnce(pageResponse([{ id: 'target-1' }], 1, false));
210
+ const after = await oxy.getUserFollowing('me', { limit: 10, offset: 0 });
211
+
212
+ expect(fetchMock).toHaveBeenCalledTimes(3);
213
+ expect(after.following).toEqual([{ id: 'target-1' }]);
214
+ });
215
+
216
+ it('invalidates every page and sort variant, not just the one that was read', async () => {
217
+ const clearPrefixSpy = jest.spyOn(oxy, 'clearCacheByPrefix');
218
+ fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, message: 'ok' }));
219
+
220
+ await oxy.followUser('target-1');
221
+
222
+ // Prefix invalidation is what makes this page/sort agnostic — an exact-key
223
+ // clear would only bust the single variant the caller happened to read.
224
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/target-1/followers');
225
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/target-1/mutuals');
226
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me/following');
227
+ clearPrefixSpy.mockRestore();
228
+ });
229
+
230
+ it('invalidates the follower lists of every id in a bulk follow', async () => {
231
+ const clearPrefixSpy = jest.spyOn(oxy, 'clearCacheByPrefix');
232
+ fetchMock.mockResolvedValueOnce(
233
+ jsonResponse({
234
+ results: [
235
+ { userId: 'a', success: true, alreadyFollowing: false },
236
+ { userId: 'b', success: true, alreadyFollowing: false },
237
+ ],
238
+ followedCount: 2,
239
+ }),
240
+ );
241
+
242
+ await oxy.followUsers(['a', 'b']);
243
+
244
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/a/followers');
245
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/b/followers');
246
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me/following');
247
+ clearPrefixSpy.mockRestore();
248
+ });
249
+ });
250
+ });