@thorprovider/adapters 5.0.11 → 5.0.12
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/dist/index.d.mts +29 -6
- package/dist/index.d.ts +29 -6
- package/dist/index.js +294 -32
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +294 -32
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/providers/medusa/auth.test.ts +148 -25
- package/src/providers/medusa/auth.ts +129 -16
- package/src/providers/medusa/customer-orders.test.ts +142 -0
- package/src/providers/medusa/index.ts +108 -26
- package/src/providers/medusa/transforms.test.ts +45 -0
- package/src/providers/medusa/transforms.ts +7 -0
- package/src/providers/mock/catalog-query.test.ts +153 -17
- package/src/providers/mock/index.test.ts +19 -0
- package/src/providers/mock/mock.ts +189 -1
- package/.turbo/turbo-build.log +0 -18
- package/.turbo/turbo-test.log +0 -897
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/publish.log +0 -93
|
@@ -31,6 +31,8 @@ import type {
|
|
|
31
31
|
Customer,
|
|
32
32
|
GetCustomersOptions,
|
|
33
33
|
GetOrdersOptions,
|
|
34
|
+
GetCustomerOrdersPageOptions,
|
|
35
|
+
CustomerOrdersPage,
|
|
34
36
|
AdminProduct,
|
|
35
37
|
Region,
|
|
36
38
|
Address,
|
|
@@ -62,13 +64,42 @@ import { MedusaAuthProvider } from './auth';
|
|
|
62
64
|
import { getPaymentMethods } from './payment';
|
|
63
65
|
import {
|
|
64
66
|
buildChannelScopeMetadata,
|
|
65
|
-
filterByChannel,
|
|
66
67
|
validateChannelScopeAssignment,
|
|
67
68
|
} from '../../metadata';
|
|
68
69
|
|
|
69
70
|
const MEDUSA_CART_RETRIEVE_FIELDS =
|
|
70
71
|
'+items,+items.variant,+items.variant.product,+items.variant.product.thumbnail,+items.variant.options,+items.variant.options.option,+items.total,+items.unit_price,+items.item_subtotal,+items.item_total,+shipping_methods,+shipping_methods.id,+shipping_methods.shipping_option_id';
|
|
71
72
|
|
|
73
|
+
/** Default window size for the channel-scoped order-history route. */
|
|
74
|
+
const CUSTOMER_ORDERS_DEFAULT_PAGE_SIZE = 10;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Upper bound on a single order-history window. The route is never asked for an
|
|
78
|
+
* unbounded/all-orders result (design D14); a caller requesting more is clamped.
|
|
79
|
+
*/
|
|
80
|
+
const CUSTOMER_ORDERS_MAX_PAGE_SIZE = 100;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalize a 1-based page + pageSize into the backend's `limit`/`offset`.
|
|
84
|
+
* Non-integer or non-positive values fall back to the canonical defaults and the
|
|
85
|
+
* page size is clamped to {@link CUSTOMER_ORDERS_MAX_PAGE_SIZE}.
|
|
86
|
+
*/
|
|
87
|
+
function normalizeCustomerOrdersPage(options: GetCustomerOrdersPageOptions): {
|
|
88
|
+
page: number;
|
|
89
|
+
pageSize: number;
|
|
90
|
+
limit: number;
|
|
91
|
+
offset: number;
|
|
92
|
+
} {
|
|
93
|
+
const page =
|
|
94
|
+
Number.isInteger(options.page) && (options.page as number) > 0 ? (options.page as number) : 1;
|
|
95
|
+
const requested =
|
|
96
|
+
Number.isInteger(options.pageSize) && (options.pageSize as number) > 0
|
|
97
|
+
? (options.pageSize as number)
|
|
98
|
+
: CUSTOMER_ORDERS_DEFAULT_PAGE_SIZE;
|
|
99
|
+
const pageSize = Math.min(requested, CUSTOMER_ORDERS_MAX_PAGE_SIZE);
|
|
100
|
+
return { page, pageSize, limit: pageSize, offset: (page - 1) * pageSize };
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
/**
|
|
73
104
|
* Fields requested when completing a cart into an order.
|
|
74
105
|
*
|
|
@@ -777,7 +808,7 @@ export class MedusaProvider implements CommerceProvider, CartCompletionCapable {
|
|
|
777
808
|
/** Fields needed to price a product variant for the current region. */
|
|
778
809
|
private buildProductFields(regionId?: string): string {
|
|
779
810
|
// calculated_price requires region_id in Medusa v2 — only request it when available
|
|
780
|
-
const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags';
|
|
811
|
+
const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags,+categories.*';
|
|
781
812
|
const pricingFields = ',+variants.calculated_price,+variants.prices';
|
|
782
813
|
return regionId ? baseFields + pricingFields : baseFields;
|
|
783
814
|
}
|
|
@@ -2862,13 +2893,17 @@ export class MedusaProvider implements CommerceProvider, CartCompletionCapable {
|
|
|
2862
2893
|
// ============================================
|
|
2863
2894
|
|
|
2864
2895
|
/**
|
|
2865
|
-
* Get all orders for a customer (storefront).
|
|
2896
|
+
* Get all orders for a customer (storefront) as an array (legacy surface).
|
|
2866
2897
|
*
|
|
2867
|
-
*
|
|
2868
|
-
*
|
|
2898
|
+
* Delegates to the channel-scoped paginated contract
|
|
2899
|
+
* ({@link customerOrdersPage}) and returns that window's orders. The backend
|
|
2900
|
+
* (`GET /store/thor/orders`) scopes by BOTH the authenticated customer and
|
|
2901
|
+
* the sales channel resolved from the publishable key, so cross-channel
|
|
2902
|
+
* orders are excluded by the backend before pagination and **no client-side
|
|
2903
|
+
* channel filter runs**.
|
|
2869
2904
|
*
|
|
2870
|
-
* @param options - Order query options (supports
|
|
2871
|
-
* @returns Array of orders for the authenticated customer
|
|
2905
|
+
* @param options - Order query options (supports a custom fetcher)
|
|
2906
|
+
* @returns Array of orders for the authenticated customer on the store channel
|
|
2872
2907
|
*
|
|
2873
2908
|
* @example
|
|
2874
2909
|
* ```typescript
|
|
@@ -2885,40 +2920,87 @@ export class MedusaProvider implements CommerceProvider, CartCompletionCapable {
|
|
|
2885
2920
|
return customFetcher(options);
|
|
2886
2921
|
}
|
|
2887
2922
|
|
|
2888
|
-
|
|
2923
|
+
const page = await this.customerOrdersPage({
|
|
2924
|
+
page: 1,
|
|
2925
|
+
pageSize: options.limit ?? CUSTOMER_ORDERS_MAX_PAGE_SIZE,
|
|
2926
|
+
salesChannelId,
|
|
2927
|
+
});
|
|
2928
|
+
return page.orders;
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
/**
|
|
2932
|
+
* Channel-scoped, paginated order history for the authenticated customer.
|
|
2933
|
+
*
|
|
2934
|
+
* Calls the backend route `GET /store/thor/orders`, which requires the
|
|
2935
|
+
* customer session and scopes `order` by `customer_id` **and** the
|
|
2936
|
+
* `sales_channel_id` resolved from the publishable key, returning
|
|
2937
|
+
* `{ orders, count, limit, offset }` with the backend's real total computed
|
|
2938
|
+
* before pagination.
|
|
2939
|
+
*
|
|
2940
|
+
* The `salesChannelId` option is the store's explicitly configured channel.
|
|
2941
|
+
* When supplied, every returned order is asserted to belong to it; a
|
|
2942
|
+
* foreign-channel order (for example from a multi-channel publishable key that
|
|
2943
|
+
* silently resolved an arbitrary first id) fails closed with a typed error
|
|
2944
|
+
* rather than being presented as the customer's history.
|
|
2945
|
+
*
|
|
2946
|
+
* @param options - Bounded pagination window and the store's sales channel
|
|
2947
|
+
* @returns The backend's `{ orders, count, limit, offset }`
|
|
2948
|
+
*/
|
|
2949
|
+
async customerOrdersPage(
|
|
2950
|
+
options: GetCustomerOrdersPageOptions = {},
|
|
2951
|
+
): Promise<CustomerOrdersPage> {
|
|
2952
|
+
const { page, pageSize, limit, offset } = normalizeCustomerOrdersPage(options);
|
|
2953
|
+
const { salesChannelId } = options;
|
|
2954
|
+
|
|
2955
|
+
this.logger.info('Getting channel-scoped customer orders', { page, pageSize });
|
|
2889
2956
|
|
|
2890
2957
|
try {
|
|
2891
|
-
const
|
|
2958
|
+
const response = await this.withRetry(
|
|
2892
2959
|
async () =>
|
|
2893
|
-
this.sdk.
|
|
2894
|
-
|
|
2895
|
-
|
|
2960
|
+
this.sdk.client.fetch<{
|
|
2961
|
+
orders?: any[];
|
|
2962
|
+
count?: number;
|
|
2963
|
+
limit?: number;
|
|
2964
|
+
offset?: number;
|
|
2965
|
+
}>('/store/thor/orders', {
|
|
2966
|
+
method: 'GET',
|
|
2967
|
+
query: { limit, offset },
|
|
2896
2968
|
}),
|
|
2897
|
-
'
|
|
2969
|
+
'customerOrdersPage',
|
|
2898
2970
|
{},
|
|
2899
2971
|
);
|
|
2900
2972
|
|
|
2901
|
-
const
|
|
2902
|
-
this.logger.debug(`Found ${
|
|
2973
|
+
const rawOrders = response.orders ?? [];
|
|
2974
|
+
this.logger.debug(`Found ${rawOrders.length} channel-scoped orders for customer`);
|
|
2903
2975
|
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
`Channel scoping: ${scoped.length}/${transformed.length} orders match channel ${channelId}`,
|
|
2976
|
+
// Fail closed if the backend scoped to a channel other than the store's
|
|
2977
|
+
// configured one (a multi-channel key must never be silently accepted).
|
|
2978
|
+
if (salesChannelId) {
|
|
2979
|
+
const foreign = rawOrders.find(
|
|
2980
|
+
(order: any) =>
|
|
2981
|
+
typeof order?.sales_channel_id === 'string' &&
|
|
2982
|
+
order.sales_channel_id !== salesChannelId,
|
|
2912
2983
|
);
|
|
2913
|
-
|
|
2984
|
+
if (foreign) {
|
|
2985
|
+
throw new ProviderAPIError(
|
|
2986
|
+
`Channel-scoped order history returned an order outside the store's sales channel (${salesChannelId}).`,
|
|
2987
|
+
'medusa',
|
|
2988
|
+
);
|
|
2989
|
+
}
|
|
2914
2990
|
}
|
|
2915
2991
|
|
|
2916
|
-
return
|
|
2992
|
+
return {
|
|
2993
|
+
orders: rawOrders.map((order: any) => transformOrder(order)),
|
|
2994
|
+
count: typeof response.count === 'number' ? response.count : rawOrders.length,
|
|
2995
|
+
limit: typeof response.limit === 'number' ? response.limit : limit,
|
|
2996
|
+
offset: typeof response.offset === 'number' ? response.offset : offset,
|
|
2997
|
+
};
|
|
2917
2998
|
} catch (error) {
|
|
2999
|
+
if (error instanceof ProviderAPIError) throw error;
|
|
2918
3000
|
const status = (error as any)?.status || (error as any)?.statusCode;
|
|
2919
3001
|
const errorMessage = extractErrorMessage(error);
|
|
2920
3002
|
|
|
2921
|
-
this.logger.error('Error getting orders', { error: errorMessage });
|
|
3003
|
+
this.logger.error('Error getting channel-scoped orders', { error: errorMessage });
|
|
2922
3004
|
throw new ProviderAPIError(
|
|
2923
3005
|
`Failed to fetch orders: ${errorMessage}`,
|
|
2924
3006
|
'medusa',
|
|
@@ -174,4 +174,49 @@ describe('transformProduct', () => {
|
|
|
174
174
|
expect(product.priceRange.minVariantPrice.amount).toBe('')
|
|
175
175
|
expect(product.priceRange.maxVariantPrice.amount).toBe('')
|
|
176
176
|
})
|
|
177
|
+
|
|
178
|
+
it('maps the backend category relation, preserving hierarchy', () => {
|
|
179
|
+
const product = transformProduct({
|
|
180
|
+
id: 'prod_cat',
|
|
181
|
+
handle: 'with-categories',
|
|
182
|
+
title: 'With Categories',
|
|
183
|
+
description: '',
|
|
184
|
+
images: [],
|
|
185
|
+
options: [],
|
|
186
|
+
variants: [],
|
|
187
|
+
categories: [
|
|
188
|
+
{ id: 'cat_tools', name: 'Tools', handle: 'tools' },
|
|
189
|
+
{
|
|
190
|
+
id: 'cat_drills',
|
|
191
|
+
name: 'Drills',
|
|
192
|
+
handle: 'drills',
|
|
193
|
+
parent_category_id: 'cat_tools',
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
expect(product.categories).toEqual([
|
|
199
|
+
{ id: 'cat_tools', name: 'Tools', handle: 'tools', parentCategoryId: undefined },
|
|
200
|
+
{
|
|
201
|
+
id: 'cat_drills',
|
|
202
|
+
name: 'Drills',
|
|
203
|
+
handle: 'drills',
|
|
204
|
+
parentCategoryId: 'cat_tools',
|
|
205
|
+
},
|
|
206
|
+
])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('omits categories when the backend supplies none (never fabricated)', () => {
|
|
210
|
+
const product = transformProduct({
|
|
211
|
+
id: 'prod_no_cat',
|
|
212
|
+
handle: 'no-categories',
|
|
213
|
+
title: 'No Categories',
|
|
214
|
+
description: '',
|
|
215
|
+
images: [],
|
|
216
|
+
options: [],
|
|
217
|
+
variants: [],
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(product.categories).toBeUndefined()
|
|
221
|
+
})
|
|
177
222
|
})
|
|
@@ -278,6 +278,13 @@ export function transformProduct(
|
|
|
278
278
|
handle: product.collection.handle ?? '',
|
|
279
279
|
}] : undefined,
|
|
280
280
|
|
|
281
|
+
categories: product.categories?.map((category: any) => ({
|
|
282
|
+
id: category.id,
|
|
283
|
+
name: category.name ?? '',
|
|
284
|
+
handle: category.handle ?? '',
|
|
285
|
+
parentCategoryId: category.parent_category_id ?? undefined,
|
|
286
|
+
})),
|
|
287
|
+
|
|
281
288
|
productType: product.type?.value ?? undefined,
|
|
282
289
|
vendor: undefined, // Medusa doesn't have vendor field
|
|
283
290
|
tags: product.tags?.map((t: any) => t.value ?? '') ?? [],
|
|
@@ -70,9 +70,11 @@ describe('MockProvider.getCatalogProducts — scope', () => {
|
|
|
70
70
|
it('returns the full fixture catalog with no filters (no filter leak)', async () => {
|
|
71
71
|
const outcome = await makeProvider().getCatalogProducts({})
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
const productHandles = handles(outcome)
|
|
74
|
+
expect(productHandles.slice(0, 4)).toEqual(FIXTURE_ORDER)
|
|
75
|
+
expect(productHandles).toHaveLength(39)
|
|
74
76
|
if (!outcome.ok) return
|
|
75
|
-
expect(outcome.results.total).toBe(
|
|
77
|
+
expect(outcome.results.total).toBe(39)
|
|
76
78
|
})
|
|
77
79
|
|
|
78
80
|
it('retains the route category scope and resolves the handle to its products', async () => {
|
|
@@ -80,10 +82,15 @@ describe('MockProvider.getCatalogProducts — scope', () => {
|
|
|
80
82
|
categoryScope: 'jackets',
|
|
81
83
|
})
|
|
82
84
|
|
|
83
|
-
expect(handles(outcome)).
|
|
85
|
+
expect(handles(outcome)).toContain('aurora-jacket')
|
|
84
86
|
if (!outcome.ok) return
|
|
85
87
|
expect(outcome.results.appliedFilters.categories).toEqual(['jackets'])
|
|
86
|
-
expect(outcome.results.total).toBe(
|
|
88
|
+
expect(outcome.results.total).toBe(12)
|
|
89
|
+
expect(
|
|
90
|
+
outcome.results.products.every(
|
|
91
|
+
(product) => product.metadata?.categoryHandle === 'jackets',
|
|
92
|
+
),
|
|
93
|
+
).toBe(true)
|
|
87
94
|
})
|
|
88
95
|
|
|
89
96
|
it('intersects the categories filter with the route scope', async () => {
|
|
@@ -92,9 +99,14 @@ describe('MockProvider.getCatalogProducts — scope', () => {
|
|
|
92
99
|
categories: ['jackets', 'dresses'],
|
|
93
100
|
})
|
|
94
101
|
|
|
95
|
-
expect(handles(outcome)).
|
|
102
|
+
expect(handles(outcome)).toContain('aurora-jacket')
|
|
96
103
|
if (!outcome.ok) return
|
|
97
104
|
expect(outcome.results.appliedFilters.categories).toEqual(['jackets'])
|
|
105
|
+
expect(
|
|
106
|
+
outcome.results.products.every(
|
|
107
|
+
(product) => product.metadata?.categoryHandle === 'jackets',
|
|
108
|
+
),
|
|
109
|
+
).toBe(true)
|
|
98
110
|
})
|
|
99
111
|
|
|
100
112
|
it('treats an empty route-scope ∩ categories intersection as zero results, not unfiltered', async () => {
|
|
@@ -128,12 +140,20 @@ describe('MockProvider.getCatalogProducts — scope', () => {
|
|
|
128
140
|
collections: ['mock-collection-best-sellers'],
|
|
129
141
|
})
|
|
130
142
|
|
|
131
|
-
expect(handles(byHandle)).toEqual(
|
|
132
|
-
expect(handles(
|
|
143
|
+
expect(handles(byHandle)).toEqual(handles(byId))
|
|
144
|
+
expect(handles(byHandle)).toContain('aurora-jacket')
|
|
145
|
+
expect(handles(byHandle)).toContain('helios-lamp')
|
|
133
146
|
if (!byHandle.ok) return
|
|
134
147
|
expect(byHandle.results.appliedFilters.collections).toEqual([
|
|
135
148
|
'best-sellers',
|
|
136
149
|
])
|
|
150
|
+
expect(
|
|
151
|
+
byHandle.results.products.every((product) =>
|
|
152
|
+
(product.collections ?? []).some(
|
|
153
|
+
(collection) => collection.handle === 'best-sellers',
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
).toBe(true)
|
|
137
157
|
})
|
|
138
158
|
|
|
139
159
|
it('treats an unknown collection handle as zero results, not unfiltered', async () => {
|
|
@@ -161,8 +181,14 @@ describe('MockProvider.getCatalogProducts — text', () => {
|
|
|
161
181
|
text: 'dress',
|
|
162
182
|
})
|
|
163
183
|
|
|
164
|
-
expect(handles(unscoped)).
|
|
184
|
+
expect(handles(unscoped)).toContain('solis-dress')
|
|
165
185
|
expect(handles(scoped)).toEqual([])
|
|
186
|
+
if (!unscoped.ok) return
|
|
187
|
+
expect(
|
|
188
|
+
unscoped.results.products.every(
|
|
189
|
+
(product) => product.metadata?.categoryHandle === 'dresses',
|
|
190
|
+
),
|
|
191
|
+
).toBe(true)
|
|
166
192
|
})
|
|
167
193
|
|
|
168
194
|
it('excludes the other category for a term that matches two categories (CU-09)', async () => {
|
|
@@ -208,11 +234,11 @@ describe('MockProvider.getCatalogProducts — global ordering before pagination'
|
|
|
208
234
|
pageSize: 2,
|
|
209
235
|
})
|
|
210
236
|
|
|
211
|
-
expect(handles(page1)).toEqual(['
|
|
212
|
-
expect(handles(page2)).toEqual(['
|
|
237
|
+
expect(handles(page1)).toEqual(['alpine-jacket', 'amber-dress'])
|
|
238
|
+
expect(handles(page2)).toEqual(['atlas-lamp', 'aurora-jacket'])
|
|
213
239
|
if (!page1.ok || !page2.ok) return
|
|
214
|
-
expect(page1.results.total).toBe(
|
|
215
|
-
expect(page2.results.total).toBe(
|
|
240
|
+
expect(page1.results.total).toBe(39)
|
|
241
|
+
expect(page2.results.total).toBe(39)
|
|
216
242
|
})
|
|
217
243
|
|
|
218
244
|
it('orders by newest (fixture createdAt desc) across pages', async () => {
|
|
@@ -261,15 +287,17 @@ describe('MockProvider.getCatalogProducts — global ordering before pagination'
|
|
|
261
287
|
pageSize: 1,
|
|
262
288
|
})
|
|
263
289
|
|
|
264
|
-
expect(handles(page1)).toEqual(['
|
|
265
|
-
expect(handles(page2)).toEqual(['
|
|
290
|
+
expect(handles(page1)).toEqual(['alpine-jacket'])
|
|
291
|
+
expect(handles(page2)).toEqual(['aurora-jacket'])
|
|
266
292
|
if (!page1.ok) return
|
|
267
|
-
expect(page1.results.total).toBe(
|
|
293
|
+
expect(page1.results.total).toBe(17)
|
|
268
294
|
})
|
|
269
295
|
|
|
270
296
|
it('defaults to page 1 and the canonical page size of 50', async () => {
|
|
271
297
|
const outcome = await makeProvider().getCatalogProducts({})
|
|
272
|
-
|
|
298
|
+
const productHandles = handles(outcome)
|
|
299
|
+
expect(productHandles).toHaveLength(39)
|
|
300
|
+
expect(productHandles.slice(0, 4)).toEqual(FIXTURE_ORDER)
|
|
273
301
|
})
|
|
274
302
|
})
|
|
275
303
|
|
|
@@ -282,7 +310,14 @@ describe('MockProvider.getCatalogProducts — truthfulness', () => {
|
|
|
282
310
|
collections: ['best-sellers'],
|
|
283
311
|
})
|
|
284
312
|
|
|
285
|
-
expect(handles(outcome)).toEqual([
|
|
313
|
+
expect(handles(outcome)).toEqual([
|
|
314
|
+
'aurora-jacket',
|
|
315
|
+
'alpine-jacket',
|
|
316
|
+
'boreal-jacket',
|
|
317
|
+
'ember-jacket',
|
|
318
|
+
'meridian-jacket',
|
|
319
|
+
'vantage-jacket',
|
|
320
|
+
])
|
|
286
321
|
if (!outcome.ok) return
|
|
287
322
|
expect(outcome.results.appliedFilters).toEqual({
|
|
288
323
|
text: 'jacket',
|
|
@@ -353,3 +388,104 @@ describe('MockProvider.getCatalogProducts — truthfulness', () => {
|
|
|
353
388
|
expect(outcome.error.fields).toContain('inStock')
|
|
354
389
|
})
|
|
355
390
|
})
|
|
391
|
+
|
|
392
|
+
describe('MockProvider.getCatalogProducts — enriched demo seed (storefront-mock-demo-catalog)', () => {
|
|
393
|
+
const SEEDED_CATEGORIES = ['jackets', 'dresses', 'home', 'accessories']
|
|
394
|
+
const SEEDED_COLLECTIONS = ['new-arrivals', 'best-sellers', 'studio-essentials']
|
|
395
|
+
|
|
396
|
+
it('seeds every category with 8-12 products', async () => {
|
|
397
|
+
const provider = makeProvider()
|
|
398
|
+
|
|
399
|
+
for (const category of SEEDED_CATEGORIES) {
|
|
400
|
+
const outcome = await provider.getCatalogProducts({ categories: [category] })
|
|
401
|
+
if (!outcome.ok) throw new Error(outcome.error.message)
|
|
402
|
+
expect(outcome.results.total).toBeGreaterThanOrEqual(8)
|
|
403
|
+
expect(outcome.results.total).toBeLessThanOrEqual(12)
|
|
404
|
+
// The default page size (50) holds the whole category, so page length
|
|
405
|
+
// equals the real match count.
|
|
406
|
+
expect(outcome.results.products.length).toBe(outcome.results.total)
|
|
407
|
+
}
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('mints unique handles, ids, and titles across the whole seed', async () => {
|
|
411
|
+
const outcome = await makeProvider().getCatalogProducts({})
|
|
412
|
+
if (!outcome.ok) throw new Error(outcome.error.message)
|
|
413
|
+
|
|
414
|
+
const { products } = outcome.results
|
|
415
|
+
const handles = products.map((product) => product.handle)
|
|
416
|
+
const ids = products.map((product) => product.id)
|
|
417
|
+
const titles = products.map((product) => product.title)
|
|
418
|
+
|
|
419
|
+
expect(products.length).toBe(39)
|
|
420
|
+
expect(new Set(handles).size).toBe(handles.length)
|
|
421
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
422
|
+
expect(new Set(titles).size).toBe(titles.length)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('spreads products across collections (each >= 2, at least one in two)', async () => {
|
|
426
|
+
const provider = makeProvider()
|
|
427
|
+
|
|
428
|
+
for (const collection of SEEDED_COLLECTIONS) {
|
|
429
|
+
const outcome = await provider.getCatalogProducts({ collections: [collection] })
|
|
430
|
+
if (!outcome.ok) throw new Error(outcome.error.message)
|
|
431
|
+
expect(outcome.results.total).toBeGreaterThanOrEqual(2)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const all = await provider.getCatalogProducts({})
|
|
435
|
+
if (!all.ok) throw new Error(all.error.message)
|
|
436
|
+
expect(
|
|
437
|
+
all.results.products.some((product) => (product.collections?.length ?? 0) >= 2),
|
|
438
|
+
).toBe(true)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('narrows the result set with a text query', async () => {
|
|
442
|
+
const provider = makeProvider()
|
|
443
|
+
const all = await provider.getCatalogProducts({})
|
|
444
|
+
const narrowed = await provider.getCatalogProducts({ text: 'jacket' })
|
|
445
|
+
if (!all.ok || !narrowed.ok) throw new Error('expected ok outcome')
|
|
446
|
+
|
|
447
|
+
expect(narrowed.results.total).toBeGreaterThan(0)
|
|
448
|
+
expect(narrowed.results.total ?? 0).toBeLessThan(all.results.total ?? 0)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('produces different orderings for different sort keys', async () => {
|
|
452
|
+
const provider = makeProvider()
|
|
453
|
+
const featured = await provider.getCatalogProducts({ sort: 'featured' })
|
|
454
|
+
const name = await provider.getCatalogProducts({ sort: 'name' })
|
|
455
|
+
const newest = await provider.getCatalogProducts({ sort: 'newest' })
|
|
456
|
+
if (!featured.ok || !name.ok || !newest.ok) throw new Error('expected ok outcome')
|
|
457
|
+
|
|
458
|
+
// Featured keeps the seeded heroes first, name reorders alphabetically.
|
|
459
|
+
expect(featured.results.products[0]?.handle).toBe('aurora-jacket')
|
|
460
|
+
expect(name.results.products[0]?.handle).toBe('alpine-jacket')
|
|
461
|
+
expect(newest.results.products[0]?.handle).toBe('aurora-jacket')
|
|
462
|
+
expect(name.results.products[0]?.handle).not.toBe(
|
|
463
|
+
featured.results.products[0]?.handle,
|
|
464
|
+
)
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('returns a filtered category as a strict subset of the unfiltered catalog', async () => {
|
|
468
|
+
const provider = makeProvider()
|
|
469
|
+
const all = await provider.getCatalogProducts({})
|
|
470
|
+
const jackets = await provider.getCatalogProducts({ categories: ['jackets'] })
|
|
471
|
+
if (!all.ok || !jackets.ok) throw new Error('expected ok outcome')
|
|
472
|
+
|
|
473
|
+
const allHandles = new Set(all.results.products.map((product) => product.handle))
|
|
474
|
+
expect(jackets.results.total ?? 0).toBeLessThan(all.results.total ?? 0)
|
|
475
|
+
for (const product of jackets.results.products) {
|
|
476
|
+
expect(allHandles.has(product.handle)).toBe(true)
|
|
477
|
+
expect(product.metadata?.categoryHandle).toBe('jackets')
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('resolves a collection filter to a different set than the category filter', async () => {
|
|
482
|
+
const provider = makeProvider()
|
|
483
|
+
const jackets = await provider.getCatalogProducts({ categories: ['jackets'] })
|
|
484
|
+
const bestSellers = await provider.getCatalogProducts({
|
|
485
|
+
collections: ['best-sellers'],
|
|
486
|
+
})
|
|
487
|
+
if (!jackets.ok || !bestSellers.ok) throw new Error('expected ok outcome')
|
|
488
|
+
|
|
489
|
+
expect(handles(bestSellers)).not.toEqual(handles(jackets))
|
|
490
|
+
})
|
|
491
|
+
})
|
|
@@ -45,3 +45,22 @@ describe('MockProvider — selected shipping option', () => {
|
|
|
45
45
|
).rejects.toThrow()
|
|
46
46
|
})
|
|
47
47
|
})
|
|
48
|
+
|
|
49
|
+
describe('MockProvider — product category references', () => {
|
|
50
|
+
it('exposes the seed category reference on products', async () => {
|
|
51
|
+
const provider = new MockProvider({ currencyCode: 'USD' })
|
|
52
|
+
const products = await provider.getProducts({ first: 100 })
|
|
53
|
+
const withCategory = products.find(
|
|
54
|
+
(product) => product.categories && product.categories.length > 0,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(withCategory).toBeDefined()
|
|
58
|
+
expect(withCategory?.categories?.[0]).toEqual(
|
|
59
|
+
expect.objectContaining({
|
|
60
|
+
id: expect.any(String),
|
|
61
|
+
name: expect.any(String),
|
|
62
|
+
handle: expect.any(String),
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
})
|