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.
- package/.agent/decisions/2026-05-20-live-event-fetch-permissions-id.md +23 -0
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +13 -15
- package/src/index.d.ts +6 -2
- package/src/index.js +6 -2
- package/src/infrastructure/sanity/README.md +230 -0
- package/src/infrastructure/sanity/SanityClient.ts +105 -0
- package/src/infrastructure/sanity/clients/ContentClient.ts +164 -0
- package/src/infrastructure/sanity/examples/usage.ts +101 -0
- package/src/infrastructure/sanity/executors/FetchQueryExecutor.ts +110 -0
- package/src/infrastructure/sanity/index.ts +19 -0
- package/src/infrastructure/sanity/interfaces/ConfigProvider.ts +6 -0
- package/src/infrastructure/sanity/interfaces/FetchByIdOptions.ts +7 -0
- package/src/infrastructure/sanity/interfaces/QueryExecutor.ts +8 -0
- package/src/infrastructure/sanity/interfaces/SanityConfig.ts +10 -0
- package/src/infrastructure/sanity/interfaces/SanityError.ts +7 -0
- package/src/infrastructure/sanity/interfaces/SanityQuery.ts +5 -0
- package/src/infrastructure/sanity/interfaces/SanityResponse.ts +6 -0
- package/src/infrastructure/sanity/providers/DefaultConfigProvider.ts +38 -0
- package/src/lib/sanity/decorators/base.ts +142 -0
- package/src/lib/sanity/decorators/examples.ts +229 -0
- package/src/lib/sanity/decorators/navigate-to.ts +139 -0
- package/src/lib/sanity/decorators/need-access.ts +40 -0
- package/src/lib/sanity/decorators/page-type.ts +35 -0
- package/src/services/awards/award-query.js +71 -0
- package/src/services/contentAggregator.js +1 -1
- package/src/services/multi-user-accounts/multi-user-accounts.ts +11 -7
- package/src/services/user/memberships.ts +46 -34
- package/src/services/user/profile.ts +66 -0
- package/test/unit/infrastructure/sanity/ContentClient.test.ts +168 -0
- package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +93 -0
- package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +174 -0
- package/test/unit/infrastructure/sanity/SanityClient.test.ts +140 -0
- package/test/unit/lib/sanity/decorators/base.test.ts +368 -0
- package/test/unit/lib/sanity/decorators/navigate-to.test.ts +266 -0
- package/test/unit/lib/sanity/decorators/need-access.test.ts +89 -0
- package/test/unit/lib/sanity/decorators/page-type.test.ts +81 -0
- package/.claude/settings.local.json +0 -23
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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<
|
|
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
|
+
})
|