musora-content-services 2.160.4 → 2.161.2

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 (40) hide show
  1. package/.agent/decisions/2026-05-20-live-event-fetch-permissions-id.md +23 -0
  2. package/CHANGELOG.md +19 -0
  3. package/package.json +1 -1
  4. package/src/contentTypeConfig.js +13 -15
  5. package/src/index.d.ts +6 -2
  6. package/src/index.js +6 -2
  7. package/src/infrastructure/sanity/README.md +230 -0
  8. package/src/infrastructure/sanity/SanityClient.ts +105 -0
  9. package/src/infrastructure/sanity/clients/ContentClient.ts +164 -0
  10. package/src/infrastructure/sanity/examples/usage.ts +101 -0
  11. package/src/infrastructure/sanity/executors/FetchQueryExecutor.ts +110 -0
  12. package/src/infrastructure/sanity/index.ts +19 -0
  13. package/src/infrastructure/sanity/interfaces/ConfigProvider.ts +6 -0
  14. package/src/infrastructure/sanity/interfaces/FetchByIdOptions.ts +7 -0
  15. package/src/infrastructure/sanity/interfaces/QueryExecutor.ts +8 -0
  16. package/src/infrastructure/sanity/interfaces/SanityConfig.ts +10 -0
  17. package/src/infrastructure/sanity/interfaces/SanityError.ts +7 -0
  18. package/src/infrastructure/sanity/interfaces/SanityQuery.ts +5 -0
  19. package/src/infrastructure/sanity/interfaces/SanityResponse.ts +6 -0
  20. package/src/infrastructure/sanity/providers/DefaultConfigProvider.ts +38 -0
  21. package/src/lib/sanity/decorators/base.ts +142 -0
  22. package/src/lib/sanity/decorators/examples.ts +229 -0
  23. package/src/lib/sanity/decorators/navigate-to.ts +139 -0
  24. package/src/lib/sanity/decorators/need-access.ts +40 -0
  25. package/src/lib/sanity/decorators/page-type.ts +35 -0
  26. package/src/services/awards/award-query.js +71 -0
  27. package/src/services/contentAggregator.js +1 -1
  28. package/src/services/multi-user-accounts/multi-user-accounts.ts +11 -7
  29. package/src/services/user/memberships.ts +46 -34
  30. package/src/services/user/profile.ts +66 -0
  31. package/test/unit/infrastructure/sanity/ContentClient.test.ts +168 -0
  32. package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +93 -0
  33. package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +174 -0
  34. package/test/unit/infrastructure/sanity/SanityClient.test.ts +140 -0
  35. package/test/unit/lib/sanity/decorators/base.test.ts +368 -0
  36. package/test/unit/lib/sanity/decorators/navigate-to.test.ts +266 -0
  37. package/test/unit/lib/sanity/decorators/need-access.test.ts +89 -0
  38. package/test/unit/lib/sanity/decorators/page-type.test.ts +81 -0
  39. package/.claude/settings.local.json +0 -23
  40. package/src/services/user/profile.js +0 -43
@@ -4,22 +4,27 @@
4
4
  import './types.js'
5
5
  import { HttpClient } from '../../infrastructure/http/HttpClient'
6
6
  import { globalConfig } from '../config'
7
+ import { MultiUserAccountResponse } from "../multi-user-accounts/multi-user-accounts";
7
8
 
8
9
  const baseUrl = `/api/user-memberships`
10
+ // Magic stringed to MembershipController.php
11
+ const multiUserAccountFeatureFlag = 'multi_user_account_feature_flag'
9
12
 
10
- /**
11
- * Represents a user membership object
12
- */
13
- export interface Membership {
14
- id: number
15
- user_id: number
16
- membership_type: string
17
- start_date: string
18
- expiration_date: string | null
19
- status: string
20
- created_at: string
21
- updated_at: string
22
- [key: string]: any
13
+ // Active Purchased Subscription Data
14
+ export interface MembershipData {
15
+ type: string
16
+ name: string
17
+ expiration_date: string
18
+ is_in_trial: boolean
19
+ trial_duration: string
20
+ never_expires: boolean
21
+ }
22
+
23
+ export interface UserMembershipResponse {
24
+ user_membership_data: MembershipData[]
25
+ can_upgrade_membership: boolean // pre multiUserAccount data
26
+ sub_account_data: MultiUserAccountResponse // post multiUserAccount data
27
+ upgrade_options: UpgradeOption[] // post multiuser account data
23
28
  }
24
29
 
25
30
  /**
@@ -32,15 +37,28 @@ export interface RechargeTokens {
32
37
  storefront_access_token: string
33
38
  }
34
39
 
35
- /**
36
- * Represents the response from subscription upgrade
37
- */
38
40
  export interface UpgradeSubscriptionResponse {
39
41
  action: 'instant_upgrade' | 'shopify'
40
42
  message?: string
41
43
  url?: string
42
44
  }
43
45
 
46
+ export interface UpgradeProduct {
47
+ id: number
48
+ name: string
49
+ sku: string
50
+ price: number
51
+ monthly_price: number
52
+ includes_trial: boolean
53
+ }
54
+
55
+ export interface UpgradeOption {
56
+ annual_savings: number
57
+ lowest_monthly_cost: number
58
+ products: UpgradeProduct[] // annual + monthly products, or solely annual product with the same configuration information
59
+ }
60
+
61
+
44
62
  /**
45
63
  * Represents the response when user should create an account (no entitlements or user not found)
46
64
  */
@@ -93,21 +111,9 @@ export type RestorePurchasesResponse =
93
111
  | RestorePurchasesSuccessResponse
94
112
  | RestorePurchasesSetupAccountResponse
95
113
 
96
- /**
97
- * Fetches the authenticated user's memberships from the API.
98
- *
99
- * @returns {Promise<Array<Membership>>} - A promise that resolves to an array of membership objects.
100
- *
101
- * @throws {Error} - Throws an error if the request fails.
102
- *
103
- * @example
104
- * fetchMemberships()
105
- * .then(memberships => console.log(memberships))
106
- * .catch(error => console.error(error));
107
- */
108
- export async function fetchMemberships(): Promise<Membership[]> {
114
+ export async function fetchMemberships(): Promise<UserMembershipResponse> {
109
115
  const httpClient = new HttpClient(globalConfig.baseUrl)
110
- return httpClient.get<Membership[]>(`${baseUrl}/v1`)
116
+ return httpClient.get<UserMembershipResponse>(`${baseUrl}/v1`)
111
117
  }
112
118
 
113
119
  /**
@@ -134,6 +140,8 @@ export async function fetchRechargeTokens(): Promise<RechargeTokens> {
134
140
  /**
135
141
  * Upgrades the user's subscription or provides a prefilled add-to-cart URL.
136
142
  *
143
+ * @param {boolean} featureFlag - MultiUserAccount feature Flag - default false
144
+ *
137
145
  * @returns {Promise<UpgradeSubscriptionResponse>} A promise that resolves to an object containing either:
138
146
  * - {string} action - The action performed (e.g., 'instant_upgrade').
139
147
  * - {string} message - Success message if the subscription was upgraded immediately.
@@ -148,9 +156,10 @@ export async function fetchRechargeTokens(): Promise<RechargeTokens> {
148
156
  * .then(response => console.log(response))
149
157
  * .catch(error => console.error(error));
150
158
  */
151
- export async function upgradeSubscription(): Promise<UpgradeSubscriptionResponse> {
159
+ export async function upgradeSubscription(featureFlag = false): Promise<UpgradeSubscriptionResponse> {
160
+ let featureFlagValue = featureFlag ? 1 : 0
152
161
  const httpClient = new HttpClient(globalConfig.baseUrl)
153
- return httpClient.get<UpgradeSubscriptionResponse>(`${baseUrl}/v1/update-subscription`)
162
+ return httpClient.get<UpgradeSubscriptionResponse>(`${baseUrl}/v1/update-subscription?${multiUserAccountFeatureFlag}=${featureFlagValue}`)
154
163
  }
155
164
 
156
165
  /**
@@ -231,6 +240,8 @@ export async function restorePurchases(
231
240
  * Get the upgrade price from Basic to Plus membership.
232
241
  * Returns the price based on the user's subscription interval.
233
242
  *
243
+ * @param {boolean} featureFlag - MultiUserAccount feature Flag - default false
244
+ *
234
245
  * For monthly subscribers: Returns the monthly upgrade cost (difference between Plus and Base monthly prices, ~$5/month)
235
246
  * For yearly subscribers: Returns the monthly equivalent upgrade cost ($3.33/month from $40/year)
236
247
  * For lifetime subscribers: Returns the annual upgrade cost for songs add-on ($40/year)
@@ -254,9 +265,10 @@ export async function restorePurchases(
254
265
  * console.error('Failed to fetch upgrade price:', error)
255
266
  * })
256
267
  */
257
- export async function getUpgradePrice() {
268
+ export async function getUpgradePrice(featureFlag = false) {
269
+ let featureFlagValue = featureFlag ? 1 : 0
258
270
  const httpClient = new HttpClient(globalConfig.baseUrl)
259
- return httpClient.get(`${baseUrl}/v1/upgrade-price`)
271
+ return httpClient.get(`${baseUrl}/v1/upgrade-price?${multiUserAccountFeatureFlag}=${featureFlagValue}`)
260
272
  }
261
273
 
262
274
  /**
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @module UserProfile
3
+ */
4
+ import { globalConfig } from '../config.js'
5
+ import { GET, DELETE, HttpClient } from '../../infrastructure/http/HttpClient'
6
+ import { calculateLongestStreaks } from '../userActivity.js'
7
+ import { UserResource } from './account'
8
+
9
+ const baseUrl = `/api/user-management-system`
10
+
11
+ export interface StreakDTO {
12
+ type: 'day' | 'week'
13
+ length: number
14
+ start_date?: Date | null
15
+ end_date?: Date | null
16
+ }
17
+
18
+ export interface OtherStatsDTO {
19
+ longest_day_streak: StreakDTO
20
+ longest_week_streak: StreakDTO
21
+ total_practice_time: number
22
+ comment_likes: number
23
+ forum_post_likes: number
24
+ experience_points: number
25
+ }
26
+
27
+ interface UserStatisticsResponse {
28
+ comment_likes: number
29
+ forum_post_likes: number
30
+ experience_points: number
31
+ v1_practice_time?: number
32
+ [key: string]: unknown
33
+ }
34
+
35
+ export async function otherStats(
36
+ userId: number | null = globalConfig.sessionConfig.userId
37
+ ): Promise<OtherStatsDTO> {
38
+ const [stats, longestStreaks] = await Promise.all([
39
+ GET(`${baseUrl}/v1/users/${userId}/statistics`) as Promise<UserStatisticsResponse>,
40
+ calculateLongestStreaks(userId),
41
+ ])
42
+
43
+ return {
44
+ ...stats,
45
+ longest_day_streak: {
46
+ type: 'day',
47
+ length: longestStreaks.longestDailyStreak,
48
+ },
49
+ longest_week_streak: {
50
+ type: 'week',
51
+ length: longestStreaks.longestWeeklyStreak,
52
+ },
53
+ total_practice_time: longestStreaks.totalPracticeSeconds + (stats.v1_practice_time ?? 0),
54
+ } as OtherStatsDTO
55
+ }
56
+
57
+ export async function deleteProfilePicture(): Promise<void> {
58
+ const url = `${baseUrl}/v1/users/profile_picture`
59
+ await DELETE(url)
60
+ }
61
+
62
+ export async function updateProfileVisibility(isPublic: boolean): Promise<UserResource> {
63
+ const apiUrl = `${baseUrl}/v1/user/profile-visibility`
64
+ const httpClient = new HttpClient(globalConfig.baseUrl, globalConfig.sessionConfig.token)
65
+ return httpClient.post<UserResource>(apiUrl, { is_profile_public: isPublic })
66
+ }
@@ -0,0 +1,168 @@
1
+ import { ContentClient } from '../../../../src/infrastructure/sanity/clients/ContentClient'
2
+ import { ConfigProvider } from '../../../../src/infrastructure/sanity/interfaces/ConfigProvider'
3
+ import { QueryExecutor } from '../../../../src/infrastructure/sanity/interfaces/QueryExecutor'
4
+ import { SanityConfig } from '../../../../src/infrastructure/sanity/interfaces/SanityConfig'
5
+ import { SanityQuery } from '../../../../src/infrastructure/sanity/interfaces/SanityQuery'
6
+ import { SanityResponse } from '../../../../src/infrastructure/sanity/interfaces/SanityResponse'
7
+
8
+ describe('ContentClient', () => {
9
+ const config: SanityConfig = {
10
+ projectId: 'p',
11
+ dataset: 'd',
12
+ version: '2021-06-07',
13
+ token: 't',
14
+ }
15
+ let mockConfigProvider: jest.Mocked<ConfigProvider>
16
+ let mockExecutor: jest.Mocked<QueryExecutor>
17
+ let client: ContentClient
18
+ let capturedQueries: string[]
19
+
20
+ beforeEach(() => {
21
+ capturedQueries = []
22
+ mockConfigProvider = { getConfig: jest.fn().mockReturnValue(config) }
23
+ mockExecutor = {
24
+ execute: jest.fn().mockImplementation((q: SanityQuery) => {
25
+ capturedQueries.push(q.query)
26
+ return Promise.resolve({ result: [], ms: 1, query: q.query } as SanityResponse<any>)
27
+ }),
28
+ }
29
+ client = new ContentClient(mockConfigProvider, mockExecutor)
30
+ })
31
+
32
+ describe('fetchById', () => {
33
+ test('builds query with railcontent_id, _type and [0] suffix', async () => {
34
+ mockExecutor.execute.mockResolvedValueOnce({
35
+ result: [{ id: 42, title: 'Song' }],
36
+ ms: 1,
37
+ query: '',
38
+ } as any)
39
+ const result = await client.fetchById<{ id: number; title: string }>({
40
+ type: 'song',
41
+ id: 42,
42
+ })
43
+
44
+ expect(result).toEqual({ id: 42, title: 'Song' })
45
+ const q = mockExecutor.execute.mock.calls[0][0].query
46
+ expect(q).toContain('railcontent_id == 42')
47
+ expect(q).toContain("_type == 'song'")
48
+ expect(q).toContain("'id': railcontent_id")
49
+ expect(q.trim().endsWith('[0]')).toBe(true)
50
+ })
51
+
52
+ test('uses custom fields when provided', async () => {
53
+ mockExecutor.execute.mockResolvedValueOnce({ result: [{}], ms: 1, query: '' } as any)
54
+ await client.fetchById({
55
+ type: 'song',
56
+ id: 1,
57
+ fields: ['title', 'artist'],
58
+ })
59
+ const q = mockExecutor.execute.mock.calls[0][0].query
60
+ expect(q).toContain('{title,\n artist}')
61
+ expect(q).not.toContain("'thumbnail'")
62
+ })
63
+
64
+ test('adds children fields when includeChildren is true', async () => {
65
+ mockExecutor.execute.mockResolvedValueOnce({ result: [{}], ms: 1, query: '' } as any)
66
+ await client.fetchById({
67
+ type: 'course',
68
+ id: 1,
69
+ includeChildren: true,
70
+ })
71
+ const q = mockExecutor.execute.mock.calls[0][0].query
72
+ expect(q).toContain('child_count')
73
+ expect(q).toContain('"lessons": child[]->{')
74
+ })
75
+
76
+ test('returns null when nothing matches', async () => {
77
+ mockExecutor.execute.mockResolvedValueOnce({ result: [], ms: 1, query: '' } as any)
78
+ const result = await client.fetchById({ type: 'song', id: 999 })
79
+ expect(result).toBeNull()
80
+ })
81
+ })
82
+
83
+ describe('fetchByIds', () => {
84
+ test('returns empty array immediately when no ids supplied', async () => {
85
+ const result = await client.fetchByIds([])
86
+ expect(result).toEqual([])
87
+ expect(mockExecutor.execute).not.toHaveBeenCalled()
88
+ })
89
+
90
+ test('builds query with id list and optional type/brand filters', async () => {
91
+ mockExecutor.execute.mockResolvedValueOnce({ result: [], ms: 1, query: '' } as any)
92
+ await client.fetchByIds([1, 2, 3], 'song', 'drumeo')
93
+ const q = mockExecutor.execute.mock.calls[0][0].query
94
+ expect(q).toContain('railcontent_id in [1,2,3]')
95
+ expect(q).toContain("_type == 'song'")
96
+ expect(q).toContain('brand == "drumeo"')
97
+ })
98
+
99
+ test('omits type and brand filters when not provided', async () => {
100
+ mockExecutor.execute.mockResolvedValueOnce({ result: [], ms: 1, query: '' } as any)
101
+ await client.fetchByIds([10, 20])
102
+ const q = mockExecutor.execute.mock.calls[0][0].query
103
+ expect(q).toContain('railcontent_id in [10,20]')
104
+ expect(q).not.toContain('_type ==')
105
+ expect(q).not.toContain('brand ==')
106
+ })
107
+
108
+ test('sorts results to match input id order', async () => {
109
+ mockExecutor.execute.mockResolvedValueOnce({
110
+ result: [{ id: 3 }, { id: 1 }, { id: 2 }],
111
+ ms: 1,
112
+ query: '',
113
+ } as any)
114
+ const result = await client.fetchByIds<{ id: number }>([1, 2, 3])
115
+ expect(result.map((r) => r.id)).toEqual([1, 2, 3])
116
+ })
117
+
118
+ test('sorts by railcontent_id when id field is absent', async () => {
119
+ mockExecutor.execute.mockResolvedValueOnce({
120
+ result: [{ railcontent_id: 2 }, { railcontent_id: 1 }],
121
+ ms: 1,
122
+ query: '',
123
+ } as any)
124
+ const result = await client.fetchByIds<{ railcontent_id: number }>([1, 2])
125
+ expect(result.map((r) => r.railcontent_id)).toEqual([1, 2])
126
+ })
127
+ })
128
+
129
+ describe('fetchByBrandAndType', () => {
130
+ test('applies default limit, offset, sort', async () => {
131
+ mockExecutor.execute.mockResolvedValueOnce({ result: [], ms: 1, query: '' } as any)
132
+ await client.fetchByBrandAndType('drumeo', 'song')
133
+ const q = mockExecutor.execute.mock.calls[0][0].query
134
+ expect(q).toContain('brand == "drumeo"')
135
+ expect(q).toContain('_type == "song"')
136
+ expect(q).toContain('order(published_on desc)')
137
+ expect(q).toContain('[0...10]')
138
+ })
139
+
140
+ test('honours custom limit, offset, sort, fields', async () => {
141
+ mockExecutor.execute.mockResolvedValueOnce({ result: [], ms: 1, query: '' } as any)
142
+ await client.fetchByBrandAndType('pianote', 'course', {
143
+ limit: 5,
144
+ offset: 20,
145
+ sortBy: 'title asc',
146
+ fields: ['title'],
147
+ })
148
+ const q = mockExecutor.execute.mock.calls[0][0].query
149
+ expect(q).toContain('order(title asc)')
150
+ expect(q).toContain('[20...25]')
151
+ expect(q).toContain('{title}')
152
+ })
153
+
154
+ test('returns list from executor', async () => {
155
+ const items = [{ id: 1 }, { id: 2 }]
156
+ mockExecutor.execute.mockResolvedValueOnce({ result: items, ms: 1, query: '' } as any)
157
+ const result = await client.fetchByBrandAndType<{ id: number }>('drumeo', 'song')
158
+ expect(result).toEqual(items)
159
+ })
160
+
161
+ test('rethrows wrapped SanityError on executor failure', async () => {
162
+ mockExecutor.execute.mockRejectedValueOnce(new Error('boom'))
163
+ await expect(client.fetchByBrandAndType('drumeo', 'song')).rejects.toMatchObject({
164
+ message: 'boom',
165
+ })
166
+ })
167
+ })
168
+ })
@@ -0,0 +1,93 @@
1
+ import { DefaultConfigProvider } from '../../../../src/infrastructure/sanity/providers/DefaultConfigProvider'
2
+ import { globalConfig } from '../../../../src/services/config.js'
3
+
4
+ jest.mock('../../../../src/services/config.js', () => ({
5
+ globalConfig: {
6
+ sanityConfig: null,
7
+ },
8
+ }))
9
+
10
+ describe('DefaultConfigProvider', () => {
11
+ let provider: DefaultConfigProvider
12
+
13
+ beforeEach(() => {
14
+ provider = new DefaultConfigProvider()
15
+ ;(globalConfig as any).sanityConfig = null
16
+ })
17
+
18
+ test('throws when sanityConfig is missing from globalConfig', () => {
19
+ ;(globalConfig as any).sanityConfig = null
20
+ expect(() => provider.getConfig()).toThrow('Sanity configuration is not available in globalConfig')
21
+ })
22
+
23
+ test('throws when token is missing', () => {
24
+ ;(globalConfig as any).sanityConfig = {
25
+ projectId: 'p',
26
+ dataset: 'd',
27
+ version: 'v',
28
+ }
29
+ expect(() => provider.getConfig()).toThrow('Sanity token is missing in configuration')
30
+ })
31
+
32
+ test('throws when projectId is missing', () => {
33
+ ;(globalConfig as any).sanityConfig = {
34
+ token: 't',
35
+ dataset: 'd',
36
+ version: 'v',
37
+ }
38
+ expect(() => provider.getConfig()).toThrow('Sanity projectId is missing in configuration')
39
+ })
40
+
41
+ test('throws when dataset is missing', () => {
42
+ ;(globalConfig as any).sanityConfig = {
43
+ token: 't',
44
+ projectId: 'p',
45
+ version: 'v',
46
+ }
47
+ expect(() => provider.getConfig()).toThrow('Sanity dataset is missing in configuration')
48
+ })
49
+
50
+ test('throws when version is missing', () => {
51
+ ;(globalConfig as any).sanityConfig = {
52
+ token: 't',
53
+ projectId: 'p',
54
+ dataset: 'd',
55
+ }
56
+ expect(() => provider.getConfig()).toThrow('Sanity version is missing in configuration')
57
+ })
58
+
59
+ test('returns config with defaults when optional fields are absent', () => {
60
+ ;(globalConfig as any).sanityConfig = {
61
+ token: 't',
62
+ projectId: 'p',
63
+ dataset: 'd',
64
+ version: '2021-06-07',
65
+ }
66
+ const config = provider.getConfig()
67
+ expect(config).toEqual({
68
+ projectId: 'p',
69
+ dataset: 'd',
70
+ version: '2021-06-07',
71
+ token: 't',
72
+ perspective: 'published',
73
+ useCachedAPI: false,
74
+ debug: false,
75
+ })
76
+ })
77
+
78
+ test('passes through optional perspective, useCachedAPI, debug', () => {
79
+ ;(globalConfig as any).sanityConfig = {
80
+ token: 't',
81
+ projectId: 'p',
82
+ dataset: 'd',
83
+ version: '2021-06-07',
84
+ perspective: 'previewDrafts',
85
+ useCachedAPI: true,
86
+ debug: true,
87
+ }
88
+ const config = provider.getConfig()
89
+ expect(config.perspective).toBe('previewDrafts')
90
+ expect(config.useCachedAPI).toBe(true)
91
+ expect(config.debug).toBe(true)
92
+ })
93
+ })
@@ -0,0 +1,174 @@
1
+ import { FetchQueryExecutor } from '../../../../src/infrastructure/sanity/executors/FetchQueryExecutor'
2
+ import { SanityConfig } from '../../../../src/infrastructure/sanity/interfaces/SanityConfig'
3
+
4
+ describe('FetchQueryExecutor', () => {
5
+ const baseConfig: SanityConfig = {
6
+ projectId: 'proj',
7
+ dataset: 'prod',
8
+ version: '2021-06-07',
9
+ token: 'tok',
10
+ }
11
+
12
+ let fetchMock: jest.Mock
13
+ const originalFetch = global.fetch
14
+
15
+ beforeEach(() => {
16
+ fetchMock = jest.fn()
17
+ global.fetch = fetchMock as any
18
+ })
19
+
20
+ afterEach(() => {
21
+ global.fetch = originalFetch
22
+ })
23
+
24
+ function makeResponse(body: any, ok = true, status = 200, statusText = 'OK'): Response {
25
+ return {
26
+ ok,
27
+ status,
28
+ statusText,
29
+ json: async () => body,
30
+ } as any
31
+ }
32
+
33
+ test('posts to non-cached endpoint with default published perspective', async () => {
34
+ fetchMock.mockResolvedValue(makeResponse({ result: [], ms: 1, query: 'q' }))
35
+ const executor = new FetchQueryExecutor()
36
+ await executor.execute({ query: 'q', params: { a: 1 } }, baseConfig)
37
+
38
+ expect(fetchMock).toHaveBeenCalledTimes(1)
39
+ const [url, options] = fetchMock.mock.calls[0]
40
+ expect(url).toBe(
41
+ 'https://proj.api.sanity.io/v2021-06-07/data/query/prod?perspective=published'
42
+ )
43
+ expect(options.method).toBe('POST')
44
+ expect(options.headers['Authorization']).toBe('Bearer tok')
45
+ expect(options.headers['Content-Type']).toBe('application/json')
46
+ expect(options.body).toBe(JSON.stringify({ query: 'q', params: { a: 1 } }))
47
+ })
48
+
49
+ test('uses apicdn endpoint when useCachedAPI is true', async () => {
50
+ fetchMock.mockResolvedValue(makeResponse({ result: [] }))
51
+ const executor = new FetchQueryExecutor()
52
+ await executor.execute({ query: 'q' }, { ...baseConfig, useCachedAPI: true })
53
+
54
+ expect(fetchMock.mock.calls[0][0]).toBe(
55
+ 'https://proj.apicdn.sanity.io/v2021-06-07/data/query/prod?perspective=published&query=q'
56
+ )
57
+ })
58
+
59
+ test('uses GET with encoded query when no params and URL under length limit', async () => {
60
+ fetchMock.mockResolvedValue(makeResponse({ result: [] }))
61
+ const executor = new FetchQueryExecutor()
62
+ await executor.execute({ query: '*[_type == "foo"]' }, baseConfig)
63
+
64
+ const [url, options] = fetchMock.mock.calls[0]
65
+ expect(url).toBe(
66
+ 'https://proj.api.sanity.io/v2021-06-07/data/query/prod?perspective=published&query=' +
67
+ encodeURIComponent('*[_type == "foo"]')
68
+ )
69
+ expect(options.method).toBe('GET')
70
+ expect(options.headers['Authorization']).toBe('Bearer tok')
71
+ expect(options.body).toBeUndefined()
72
+ })
73
+
74
+ test('falls back to POST when params are provided', async () => {
75
+ fetchMock.mockResolvedValue(makeResponse({ result: [] }))
76
+ const executor = new FetchQueryExecutor()
77
+ await executor.execute({ query: 'q', params: { id: 1 } }, baseConfig)
78
+
79
+ const [url, options] = fetchMock.mock.calls[0]
80
+ expect(url).toBe(
81
+ 'https://proj.api.sanity.io/v2021-06-07/data/query/prod?perspective=published'
82
+ )
83
+ expect(options.method).toBe('POST')
84
+ expect(options.body).toBe(JSON.stringify({ query: 'q', params: { id: 1 } }))
85
+ })
86
+
87
+ test('falls back to POST when GET URL would exceed length limit', async () => {
88
+ fetchMock.mockResolvedValue(makeResponse({ result: [] }))
89
+ const executor = new FetchQueryExecutor()
90
+ const longQuery = 'a'.repeat(8001)
91
+ await executor.execute({ query: longQuery }, baseConfig)
92
+
93
+ const [url, options] = fetchMock.mock.calls[0]
94
+ expect(url).toBe(
95
+ 'https://proj.api.sanity.io/v2021-06-07/data/query/prod?perspective=published'
96
+ )
97
+ expect(options.method).toBe('POST')
98
+ expect(options.body).toBe(JSON.stringify({ query: longQuery }))
99
+ })
100
+
101
+ test('honours custom perspective', async () => {
102
+ fetchMock.mockResolvedValue(makeResponse({ result: [] }))
103
+ const executor = new FetchQueryExecutor()
104
+ await executor.execute({ query: 'q' }, { ...baseConfig, perspective: 'previewDrafts' })
105
+
106
+ expect(fetchMock.mock.calls[0][0]).toContain('perspective=previewDrafts')
107
+ })
108
+
109
+ test('returns parsed response body on success', async () => {
110
+ const body = { result: [{ id: 1 }], ms: 4, query: 'q' }
111
+ fetchMock.mockResolvedValue(makeResponse(body))
112
+ const executor = new FetchQueryExecutor()
113
+ const out = await executor.execute({ query: 'q' }, baseConfig)
114
+ expect(out).toEqual(body)
115
+ })
116
+
117
+ test('throws SanityError using response body message when fetch is not ok', async () => {
118
+ fetchMock.mockResolvedValue(
119
+ makeResponse({ message: 'GROQ parse error' }, false, 400, 'Bad Request')
120
+ )
121
+ const executor = new FetchQueryExecutor()
122
+ await expect(executor.execute({ query: 'q', params: { a: 1 } }, baseConfig)).rejects.toMatchObject({
123
+ message: 'GROQ parse error',
124
+ query: 'q',
125
+ params: { a: 1 },
126
+ })
127
+ })
128
+
129
+ test('falls back to status/statusText when error body cannot be parsed', async () => {
130
+ const response = {
131
+ ok: false,
132
+ status: 500,
133
+ statusText: 'Server Error',
134
+ json: async () => {
135
+ throw new Error('not json')
136
+ },
137
+ } as any
138
+ fetchMock.mockResolvedValue(response)
139
+ const executor = new FetchQueryExecutor()
140
+ await expect(executor.execute({ query: 'q' }, baseConfig)).rejects.toMatchObject({
141
+ message: 'Sanity API error: 500 - Server Error',
142
+ query: 'q',
143
+ })
144
+ })
145
+
146
+ test('wraps thrown network errors as SanityError', async () => {
147
+ const networkErr = new Error('connection refused')
148
+ fetchMock.mockRejectedValue(networkErr)
149
+ const executor = new FetchQueryExecutor()
150
+ await expect(executor.execute({ query: 'q' }, baseConfig)).rejects.toMatchObject({
151
+ message: 'connection refused',
152
+ query: 'q',
153
+ originalError: networkErr,
154
+ })
155
+ })
156
+
157
+ test('does not re-wrap an existing SanityError', async () => {
158
+ const sanityErr = { message: 'already wrapped', query: 'q' }
159
+ fetchMock.mockRejectedValue(sanityErr)
160
+ const executor = new FetchQueryExecutor()
161
+ await expect(executor.execute({ query: 'q' }, baseConfig)).rejects.toEqual(sanityErr)
162
+ })
163
+
164
+ test('debug mode logs query and result', async () => {
165
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
166
+ const body = { result: [], ms: 1, query: 'q' }
167
+ fetchMock.mockResolvedValue(makeResponse(body))
168
+ const executor = new FetchQueryExecutor()
169
+ await executor.execute({ query: 'dbg' }, { ...baseConfig, debug: true })
170
+ expect(logSpy).toHaveBeenCalledWith('Sanity Query:', 'dbg')
171
+ expect(logSpy).toHaveBeenCalledWith('Sanity Results:', body)
172
+ logSpy.mockRestore()
173
+ })
174
+ })