@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.
- package/README.md +36 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +4 -13
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +0 -31
- package/dist/cjs/mixins/OxyServices.user.js +50 -44
- package/dist/cjs/server/index.js +9 -6
- package/dist/cjs/server/rateLimit.js +3 -0
- package/dist/cjs/server/securityHeaders.js +234 -0
- package/dist/cjs/session/accountDialogController.js +6 -8
- package/dist/cjs/utils/apiUtils.js +40 -10
- package/dist/cjs/utils/oauthPkce.js +1 -5
- package/dist/cjs/utils/officialOrigins.js +3 -73
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +3 -4
- package/dist/esm/mixins/OxyServices.deviceBoot.js +1 -32
- package/dist/esm/mixins/OxyServices.user.js +51 -45
- package/dist/esm/server/index.js +4 -1
- package/dist/esm/server/rateLimit.js +3 -0
- package/dist/esm/server/securityHeaders.js +224 -0
- package/dist/esm/session/accountDialogController.js +6 -8
- package/dist/esm/utils/apiUtils.js +39 -10
- package/dist/esm/utils/oauthPkce.js +0 -4
- package/dist/esm/utils/officialOrigins.js +3 -68
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +5 -7
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -12
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +1 -5
- package/dist/types/mixins/OxyServices.user.d.ts +27 -6
- package/dist/types/server/index.d.ts +3 -1
- package/dist/types/server/securityHeaders.d.ts +154 -0
- package/dist/types/session/accountDialogController.d.ts +9 -15
- package/dist/types/utils/apiUtils.d.ts +48 -6
- package/dist/types/utils/oauthPkce.d.ts +11 -7
- package/dist/types/utils/officialOrigins.d.ts +3 -13
- package/package.json +10 -5
- package/src/index.ts +9 -14
- package/src/mixins/OxyServices.auth.ts +6 -13
- package/src/mixins/OxyServices.deviceBoot.ts +0 -47
- package/src/mixins/OxyServices.user.ts +60 -49
- package/src/mixins/__tests__/commonsSignIn.test.ts +9 -2
- package/src/mixins/__tests__/followGraphPagination.test.ts +250 -0
- package/src/server/__tests__/securityHeaders.test.ts +244 -0
- package/src/server/index.ts +17 -8
- package/src/server/rateLimit.ts +3 -0
- package/src/server/securityHeaders.ts +304 -0
- package/src/session/__tests__/accountDialogController.test.ts +3 -5
- package/src/session/accountDialogController.ts +12 -18
- package/src/utils/__tests__/officialOrigins.test.ts +0 -57
- package/src/utils/apiUtils.ts +64 -15
- package/src/utils/oauthPkce.ts +11 -9
- package/src/utils/officialOrigins.ts +3 -70
- package/dist/cjs/session/hubSync.js +0 -55
- package/dist/esm/session/hubSync.js +0 -51
- package/dist/types/session/hubSync.d.ts +0 -20
- package/src/session/__tests__/hubSync.test.ts +0 -51
- 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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?:
|
|
917
|
+
pagination?: FollowGraphParams
|
|
907
918
|
): Promise<{ followers: User[]; total: number; hasMore: boolean }> {
|
|
908
919
|
try {
|
|
909
|
-
const params =
|
|
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?:
|
|
940
|
+
pagination?: FollowGraphParams
|
|
930
941
|
): Promise<{ following: User[]; total: number; hasMore: boolean }> {
|
|
931
942
|
try {
|
|
932
|
-
const params =
|
|
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?:
|
|
964
|
+
pagination?: FollowGraphParams
|
|
954
965
|
): Promise<{ mutuals: User[]; total: number; hasMore: boolean }> {
|
|
955
966
|
try {
|
|
956
|
-
const params =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|