@thorprovider/adapters 5.0.10 → 5.0.11
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/.turbo/turbo-build.log +9 -9
- package/.turbo/turbo-test.log +564 -39
- package/AGENTS.md +1 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.d.mts +348 -11
- package/dist/index.d.ts +348 -11
- package/dist/index.js +1393 -496
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1386 -498
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/core/errors.ts +31 -0
- package/src/factory/index.ts +16 -6
- package/src/factory/provider-types.test.ts +32 -0
- package/src/factory/provider-types.ts +176 -0
- package/src/index.ts +6 -4
- package/src/providers/index.ts +1 -1
- package/src/providers/medusa/available-filters.test.ts +280 -0
- package/src/providers/medusa/catalog-facet-support.test.ts +715 -0
- package/src/providers/medusa/catalog-query.test.ts +422 -0
- package/src/providers/medusa/catalog-sort.test.ts +175 -0
- package/src/providers/medusa/index.test.ts +480 -0
- package/src/providers/medusa/index.ts +1490 -518
- package/src/providers/medusa/live-smoke.test.ts +68 -0
- package/src/providers/medusa/transforms.test.ts +177 -0
- package/src/providers/medusa/transforms.ts +145 -66
- package/src/providers/mock/catalog-query.test.ts +355 -0
- package/src/providers/mock/index.test.ts +47 -0
- package/src/providers/mock/index.ts +43 -1
- package/src/providers/mock/mock.ts +324 -2
- package/src/types/completion.ts +57 -0
- package/src/types/index.ts +7 -0
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thorprovider/adapters",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.11",
|
|
4
4
|
"description": "Framework-agnostic commerce adapters for Thor Commerce ecosystem - Multi-platform support with unified API",
|
|
5
5
|
"publishConfig": {
|
|
6
|
+
"registry": "https://registry.npmjs.org",
|
|
6
7
|
"access": "public"
|
|
7
8
|
},
|
|
8
9
|
"main": "dist/index.js",
|
package/src/core/errors.ts
CHANGED
|
@@ -37,6 +37,37 @@ export class ConfigurationError extends CommerceError {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Storefront validation error.
|
|
42
|
+
* Thrown when storefront configuration is invalid or missing.
|
|
43
|
+
*
|
|
44
|
+
* Moved here from `@thorprovider/types` so that package stays type-only.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```typescript
|
|
48
|
+
* throw new StorefrontConfigError(
|
|
49
|
+
* 'Medusa requires NEXT_PUBLIC_SALES_CHANNEL_ID',
|
|
50
|
+
* 'medusa',
|
|
51
|
+
* 'NEXT_PUBLIC_SALES_CHANNEL_ID',
|
|
52
|
+
* );
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export class StorefrontConfigError extends Error {
|
|
56
|
+
public readonly platformType: string;
|
|
57
|
+
public readonly requiredEnvVar?: string;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
message: string,
|
|
61
|
+
platformType: string,
|
|
62
|
+
requiredEnvVar?: string,
|
|
63
|
+
) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = 'StorefrontConfigError';
|
|
66
|
+
this.platformType = platformType;
|
|
67
|
+
this.requiredEnvVar = requiredEnvVar;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
40
71
|
/**
|
|
41
72
|
* Resource not found error
|
|
42
73
|
*/
|
package/src/factory/index.ts
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
* @thorprovider/adapters v2.0
|
|
3
3
|
* Provider Factory - Create commerce providers dynamically
|
|
4
4
|
*
|
|
5
|
-
* This factory
|
|
5
|
+
* This factory owns the runtime provider registry (SupportedProviderType +
|
|
6
|
+
* metadata + guards) and is bound to the type-only ProviderTypeString union
|
|
7
|
+
* from @thorprovider/types (L1).
|
|
6
8
|
* When adding a new provider:
|
|
7
|
-
* 1. Update
|
|
8
|
-
* 2. Add
|
|
9
|
-
* 3.
|
|
10
|
-
* 4.
|
|
9
|
+
* 1. Update ProviderTypeString in packages/types/src/provider.ts
|
|
10
|
+
* 2. Add the enum member + metadata in ./provider-types.ts
|
|
11
|
+
* 3. Add new discriminated union case here
|
|
12
|
+
* 4. Create packages/adapters/src/providers/[platform]/
|
|
13
|
+
* 5. Implement CommerceProvider interface
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import type { CommerceProvider } from '../types';
|
|
@@ -22,8 +25,15 @@ import { MockProvider, type MockProviderConfig } from '../providers/mock';
|
|
|
22
25
|
import { ProviderNotFoundError, ConfigurationError } from '../core/errors';
|
|
23
26
|
import type { ProviderTypeString } from '@thorprovider/types';
|
|
24
27
|
|
|
28
|
+
export {
|
|
29
|
+
SupportedProviderType,
|
|
30
|
+
PROVIDER_METADATA,
|
|
31
|
+
isSupportedProviderType,
|
|
32
|
+
getProviderMetadata,
|
|
33
|
+
} from './provider-types';
|
|
34
|
+
|
|
25
35
|
/**
|
|
26
|
-
* Supported provider types (
|
|
36
|
+
* Supported provider types (type-only union owned by @thorprovider/types L1)
|
|
27
37
|
* Kept for backward compatibility; prefer ProviderTypeString from @thorprovider/types
|
|
28
38
|
*/
|
|
29
39
|
export type ProviderType = ProviderTypeString;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
PROVIDER_METADATA,
|
|
4
|
+
SupportedProviderType,
|
|
5
|
+
getProviderMetadata,
|
|
6
|
+
isSupportedProviderType,
|
|
7
|
+
} from './provider-types'
|
|
8
|
+
|
|
9
|
+
describe('provider registry (moved from @thorprovider/types)', () => {
|
|
10
|
+
it('accepts supported provider strings and narrows the type', () => {
|
|
11
|
+
expect(isSupportedProviderType('medusa')).toBe(true)
|
|
12
|
+
expect(isSupportedProviderType('mock')).toBe(true)
|
|
13
|
+
expect(isSupportedProviderType('stelorder')).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('rejects unsupported or non-string values', () => {
|
|
17
|
+
expect(isSupportedProviderType('bogus')).toBe(false)
|
|
18
|
+
expect(isSupportedProviderType(42)).toBe(false)
|
|
19
|
+
expect(isSupportedProviderType(undefined)).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('has metadata for every enum member', () => {
|
|
23
|
+
for (const value of Object.values(SupportedProviderType)) {
|
|
24
|
+
expect(PROVIDER_METADATA[value]).toBeDefined()
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns metadata for a supported provider', () => {
|
|
29
|
+
expect(getProviderMetadata('medusa').name).toBe('Medusa JS')
|
|
30
|
+
expect(getProviderMetadata('mock').maxRetries).toBe(0)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @thorprovider/adapters — Provider registry (runtime)
|
|
3
|
+
*
|
|
4
|
+
* Runtime owner of the provider registry: the `SupportedProviderType` enum,
|
|
5
|
+
* its metadata, and the guards. The provider-type union contract
|
|
6
|
+
* (`ProviderTypeString`) stays type-only in `@thorprovider/types`.
|
|
7
|
+
*
|
|
8
|
+
* When adding a new provider:
|
|
9
|
+
* 1. Add the string to `ProviderTypeString` in packages/types/src/provider.ts.
|
|
10
|
+
* 2. Add the enum member + metadata entry here.
|
|
11
|
+
* 3. Create packages/adapters/src/providers/[platform]/.
|
|
12
|
+
*/
|
|
13
|
+
import type { ProviderTypeString } from '@thorprovider/types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* All supported commerce platform providers.
|
|
17
|
+
* `mock` is the backend-optional runtime provider used for layout/design mode.
|
|
18
|
+
* `medusa` is fully implemented for real backend usage; others are placeholders for future expansion.
|
|
19
|
+
*
|
|
20
|
+
* @remarks Implementation status:
|
|
21
|
+
* - ✅ mock: Fixture-backed provider for backend-optional storefronts
|
|
22
|
+
* - ✅ medusa: Fully implemented
|
|
23
|
+
* - 🟡 stelorder: In progress (ERP adapter v1)
|
|
24
|
+
* - 🟡 shopify: Planned (Phase 2)
|
|
25
|
+
* - 🟡 bigcommerce: Planned (Phase 2)
|
|
26
|
+
* - 🟡 woocommerce: Planned (Phase 3)
|
|
27
|
+
* - 🟡 spree: Planned (Phase 3)
|
|
28
|
+
* - 🟡 magento: Planned (Phase 3)
|
|
29
|
+
*/
|
|
30
|
+
export enum SupportedProviderType {
|
|
31
|
+
Mock = 'mock',
|
|
32
|
+
Medusa = 'medusa',
|
|
33
|
+
StelOrder = 'stelorder',
|
|
34
|
+
Shopify = 'shopify',
|
|
35
|
+
BigCommerce = 'bigcommerce',
|
|
36
|
+
WooCommerce = 'woocommerce',
|
|
37
|
+
Spree = 'spree',
|
|
38
|
+
Magento = 'magento',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Metadata about each provider.
|
|
43
|
+
* Useful for UI, error messages, validation, and documentation.
|
|
44
|
+
*/
|
|
45
|
+
export const PROVIDER_METADATA: Record<
|
|
46
|
+
ProviderTypeString,
|
|
47
|
+
{
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
requiresStorefront: boolean;
|
|
51
|
+
requiredEnvVars: string[];
|
|
52
|
+
maxRetries: number;
|
|
53
|
+
}
|
|
54
|
+
> = {
|
|
55
|
+
mock: {
|
|
56
|
+
name: 'Mock',
|
|
57
|
+
description: 'Fixture-backed runtime provider for layout/design mode without a real backend',
|
|
58
|
+
requiresStorefront: false,
|
|
59
|
+
requiredEnvVars: [],
|
|
60
|
+
maxRetries: 0,
|
|
61
|
+
},
|
|
62
|
+
medusa: {
|
|
63
|
+
name: 'Medusa JS',
|
|
64
|
+
description: 'Medusa v2 commerce engine',
|
|
65
|
+
requiresStorefront: true,
|
|
66
|
+
requiredEnvVars: [
|
|
67
|
+
'NEXT_PUBLIC_COMMERCE_API_URL',
|
|
68
|
+
'NEXT_PUBLIC_COMMERCE_API_KEY',
|
|
69
|
+
'NEXT_PUBLIC_SALES_CHANNEL_ID',
|
|
70
|
+
],
|
|
71
|
+
maxRetries: 3,
|
|
72
|
+
},
|
|
73
|
+
stelorder: {
|
|
74
|
+
name: 'Stel Order',
|
|
75
|
+
description: 'Stel Order ERP and sales document API',
|
|
76
|
+
requiresStorefront: false,
|
|
77
|
+
requiredEnvVars: [
|
|
78
|
+
'NEXT_PUBLIC_COMMERCE_API_URL',
|
|
79
|
+
'NEXT_PUBLIC_COMMERCE_API_KEY',
|
|
80
|
+
],
|
|
81
|
+
maxRetries: 2,
|
|
82
|
+
},
|
|
83
|
+
shopify: {
|
|
84
|
+
name: 'Shopify',
|
|
85
|
+
description: 'Shopify Storefront API (GraphQL)',
|
|
86
|
+
requiresStorefront: false,
|
|
87
|
+
requiredEnvVars: [
|
|
88
|
+
'NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN',
|
|
89
|
+
'NEXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN',
|
|
90
|
+
],
|
|
91
|
+
maxRetries: 3,
|
|
92
|
+
},
|
|
93
|
+
bigcommerce: {
|
|
94
|
+
name: 'BigCommerce',
|
|
95
|
+
description: 'BigCommerce REST Storefront API',
|
|
96
|
+
requiresStorefront: false,
|
|
97
|
+
requiredEnvVars: [
|
|
98
|
+
'BIGCOMMERCE_STORE_HASH',
|
|
99
|
+
'BIGCOMMERCE_STOREFRONT_API_TOKEN',
|
|
100
|
+
'BIGCOMMERCE_CHANNEL_ID',
|
|
101
|
+
],
|
|
102
|
+
maxRetries: 3,
|
|
103
|
+
},
|
|
104
|
+
woocommerce: {
|
|
105
|
+
name: 'WooCommerce',
|
|
106
|
+
description: 'WooCommerce Store API + REST API v3',
|
|
107
|
+
requiresStorefront: false,
|
|
108
|
+
requiredEnvVars: [
|
|
109
|
+
'WOOCOMMERCE_URL',
|
|
110
|
+
'WOOCOMMERCE_CONSUMER_KEY',
|
|
111
|
+
'WOOCOMMERCE_CONSUMER_SECRET',
|
|
112
|
+
],
|
|
113
|
+
maxRetries: 3,
|
|
114
|
+
},
|
|
115
|
+
spree: {
|
|
116
|
+
name: 'Spree Commerce',
|
|
117
|
+
description: 'Spree API v2 Storefront (JSON:API)',
|
|
118
|
+
requiresStorefront: false,
|
|
119
|
+
requiredEnvVars: [
|
|
120
|
+
'SPREE_API_URL',
|
|
121
|
+
],
|
|
122
|
+
maxRetries: 3,
|
|
123
|
+
},
|
|
124
|
+
magento: {
|
|
125
|
+
name: 'Adobe Commerce (Magento)',
|
|
126
|
+
description: 'Adobe Commerce GraphQL + REST API',
|
|
127
|
+
requiresStorefront: true,
|
|
128
|
+
requiredEnvVars: [
|
|
129
|
+
'MAGENTO_URL',
|
|
130
|
+
'MAGENTO_STORE_CODE',
|
|
131
|
+
],
|
|
132
|
+
maxRetries: 3,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if a string is a valid provider type.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```typescript
|
|
141
|
+
* if (isSupportedProviderType(process.env.COMMERCE_PROVIDER)) {
|
|
142
|
+
* // type is narrowed to ProviderTypeString
|
|
143
|
+
* }
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
export function isSupportedProviderType(
|
|
147
|
+
value: unknown
|
|
148
|
+
): value is ProviderTypeString {
|
|
149
|
+
return (
|
|
150
|
+
typeof value === 'string' &&
|
|
151
|
+
(Object.values(SupportedProviderType) as string[]).includes(value)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get metadata for a provider type.
|
|
157
|
+
* Useful for validation, error messages, and logging.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```typescript
|
|
161
|
+
* const meta = getProviderMetadata('medusa');
|
|
162
|
+
* console.log(`Using ${meta.name} with max ${meta.maxRetries} retries`);
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
export function getProviderMetadata(
|
|
166
|
+
type: ProviderTypeString
|
|
167
|
+
): (typeof PROVIDER_METADATA)[ProviderTypeString] {
|
|
168
|
+
if (!isSupportedProviderType(type)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Unsupported provider: ${type}. Supported: ${Object.values(
|
|
171
|
+
SupportedProviderType
|
|
172
|
+
).join(', ')}`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return PROVIDER_METADATA[type];
|
|
176
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,10 +30,6 @@ export type {
|
|
|
30
30
|
DesignerHistoryEntry,
|
|
31
31
|
} from '@thorprovider/types';
|
|
32
32
|
|
|
33
|
-
export {
|
|
34
|
-
StorefrontConfigError,
|
|
35
|
-
} from '@thorprovider/types';
|
|
36
|
-
|
|
37
33
|
// ============================================
|
|
38
34
|
// Capabilities
|
|
39
35
|
// ============================================
|
|
@@ -57,6 +53,7 @@ export {
|
|
|
57
53
|
CommerceError,
|
|
58
54
|
ProviderNotFoundError,
|
|
59
55
|
ConfigurationError,
|
|
56
|
+
StorefrontConfigError,
|
|
60
57
|
ResourceNotFoundError,
|
|
61
58
|
ProviderAPIError,
|
|
62
59
|
extractErrorMessage,
|
|
@@ -188,6 +185,10 @@ export type {
|
|
|
188
185
|
export {
|
|
189
186
|
createProvider,
|
|
190
187
|
isProviderType,
|
|
188
|
+
SupportedProviderType,
|
|
189
|
+
PROVIDER_METADATA,
|
|
190
|
+
isSupportedProviderType,
|
|
191
|
+
getProviderMetadata,
|
|
191
192
|
type ProviderType,
|
|
192
193
|
type ProviderFactoryConfig,
|
|
193
194
|
} from './factory';
|
|
@@ -201,6 +202,7 @@ export {
|
|
|
201
202
|
StelOrderProvider,
|
|
202
203
|
type StelOrderProviderConfig,
|
|
203
204
|
MockProvider,
|
|
205
|
+
MOCK_SALES_CHANNEL_ID,
|
|
204
206
|
type MockProviderConfig,
|
|
205
207
|
ShopifyProvider,
|
|
206
208
|
type ShopifyProviderConfig,
|
package/src/providers/index.ts
CHANGED
|
@@ -24,5 +24,5 @@ export type { SpreeProviderConfig } from './spree';
|
|
|
24
24
|
export { MagentoProvider } from './magento';
|
|
25
25
|
export type { MagentoProviderConfig } from './magento';
|
|
26
26
|
|
|
27
|
-
export { MockProvider } from './mock';
|
|
27
|
+
export { MockProvider, MOCK_SALES_CHANNEL_ID } from './mock';
|
|
28
28
|
export type { MockProviderConfig } from './mock';
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter facet truthfulness — E2 `storefront-catalog-search-filters` task 2.3.
|
|
3
|
+
*
|
|
4
|
+
* `getAvailableFilters` must never fabricate a numeric range or a per-option
|
|
5
|
+
* facet count. Medusa's Store `/store/products` returns matching products and a
|
|
6
|
+
* total count, not filterable values or their ranges, so no price range is read
|
|
7
|
+
* here and the control is omitted rather than defaulted to `0..10000`. A custom
|
|
8
|
+
* attribute range/count is emitted only when the backend actually supplies the
|
|
9
|
+
* finite metadata; a missing bound or count is omitted, never replaced by a
|
|
10
|
+
* `0`/`100` default or a synthesized `0`.
|
|
11
|
+
*
|
|
12
|
+
* The HTTP boundary is intercepted with msw against a controlled fixture, so
|
|
13
|
+
* the exact emitted filter shapes are asserted.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import { http, HttpResponse } from 'msw'
|
|
17
|
+
import { server } from '../stelorder/client.test-helpers'
|
|
18
|
+
import { MedusaProvider } from './index'
|
|
19
|
+
|
|
20
|
+
const BASE = 'https://medusa.example.com'
|
|
21
|
+
const PUB_KEY = 'pk_test_abc123'
|
|
22
|
+
const CURRENCY = 'usd'
|
|
23
|
+
|
|
24
|
+
const CATEGORIES = [
|
|
25
|
+
{
|
|
26
|
+
id: 'pcat_x',
|
|
27
|
+
name: 'Category X',
|
|
28
|
+
handle: 'x',
|
|
29
|
+
description: null,
|
|
30
|
+
parent_category_id: null,
|
|
31
|
+
product_count: 3,
|
|
32
|
+
metadata: null,
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const COLLECTIONS = [
|
|
37
|
+
{ id: 'pcol_c1', handle: 'c1', title: 'Collection One', metadata: null },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** A `multi-select` attribute whose values carry real backend counts. */
|
|
41
|
+
const MULTI_WITH_COUNTS = {
|
|
42
|
+
attribute_id: 'attr_ram',
|
|
43
|
+
attribute: {
|
|
44
|
+
name: 'RAM',
|
|
45
|
+
handle: 'ram',
|
|
46
|
+
type: 'multi-select',
|
|
47
|
+
is_filterable: true,
|
|
48
|
+
is_active: true,
|
|
49
|
+
ranking: 40,
|
|
50
|
+
values: [
|
|
51
|
+
{ id: 'val_8', value: '8GB', count: 15 },
|
|
52
|
+
{ id: 'val_16', value: '16GB', count: 23 },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A `multi-select` attribute whose values carry no counts. */
|
|
58
|
+
const MULTI_WITHOUT_COUNTS = {
|
|
59
|
+
attribute_id: 'attr_ram',
|
|
60
|
+
attribute: {
|
|
61
|
+
...MULTI_WITH_COUNTS.attribute,
|
|
62
|
+
values: [
|
|
63
|
+
{ id: 'val_8', value: '8GB' },
|
|
64
|
+
{ id: 'val_16', value: '16GB' },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A `number` attribute with a real backend range. */
|
|
70
|
+
const RANGE_WITH_BOUNDS = {
|
|
71
|
+
attribute_id: 'attr_price',
|
|
72
|
+
attribute: {
|
|
73
|
+
name: 'Price',
|
|
74
|
+
handle: 'price-attr',
|
|
75
|
+
type: 'number',
|
|
76
|
+
is_filterable: true,
|
|
77
|
+
is_active: true,
|
|
78
|
+
ranking: 30,
|
|
79
|
+
metadata: { min: 5, max: 250 },
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A `number` attribute with metadata but no real bounds. */
|
|
84
|
+
const RANGE_WITHOUT_BOUNDS = {
|
|
85
|
+
attribute_id: 'attr_price',
|
|
86
|
+
attribute: {
|
|
87
|
+
...RANGE_WITH_BOUNDS.attribute,
|
|
88
|
+
metadata: {},
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeProvider() {
|
|
93
|
+
return new MedusaProvider({
|
|
94
|
+
baseUrl: BASE,
|
|
95
|
+
publishableKey: PUB_KEY,
|
|
96
|
+
currencyCode: CURRENCY,
|
|
97
|
+
debug: false,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface Fixture {
|
|
102
|
+
/** Every `/store/custom-attributes/category/:id` request URL. */
|
|
103
|
+
customAttributeUrls: string[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function installFixture(
|
|
107
|
+
attributes: Array<Record<string, unknown>> = [],
|
|
108
|
+
): Fixture {
|
|
109
|
+
const fixture: Fixture = { customAttributeUrls: [] }
|
|
110
|
+
server.use(
|
|
111
|
+
// Thor plugin endpoints are absent; the static capability fallback applies
|
|
112
|
+
// (advancedSearch/customAttributes true) and channel scoping stays unscoped.
|
|
113
|
+
http.get(`${BASE}/store/thor/capabilities`, () =>
|
|
114
|
+
HttpResponse.json({}, { status: 404 }),
|
|
115
|
+
),
|
|
116
|
+
http.get(`${BASE}/store/thor/categories`, () =>
|
|
117
|
+
HttpResponse.json({}, { status: 404 }),
|
|
118
|
+
),
|
|
119
|
+
http.get(`${BASE}/store/thor/collections`, () =>
|
|
120
|
+
HttpResponse.json({}, { status: 404 }),
|
|
121
|
+
),
|
|
122
|
+
// The optional advanced endpoint is absent: the facet-support probe must
|
|
123
|
+
// treat this as "not demonstrated" rather than reading a capability flag.
|
|
124
|
+
http.get(`${BASE}/store/search`, () =>
|
|
125
|
+
HttpResponse.json(
|
|
126
|
+
{ error: 'Failed to search products', message: 'search unavailable' },
|
|
127
|
+
{ status: 500 },
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
http.get(`${BASE}/store/product-categories`, () =>
|
|
131
|
+
HttpResponse.json({
|
|
132
|
+
product_categories: CATEGORIES,
|
|
133
|
+
count: CATEGORIES.length,
|
|
134
|
+
offset: 0,
|
|
135
|
+
limit: 50,
|
|
136
|
+
}),
|
|
137
|
+
),
|
|
138
|
+
http.get(`${BASE}/store/collections`, () =>
|
|
139
|
+
HttpResponse.json({
|
|
140
|
+
collections: COLLECTIONS,
|
|
141
|
+
count: COLLECTIONS.length,
|
|
142
|
+
offset: 0,
|
|
143
|
+
limit: 100,
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
146
|
+
http.get(
|
|
147
|
+
`${BASE}/store/custom-attributes/category/:categoryId`,
|
|
148
|
+
({ request }) => {
|
|
149
|
+
fixture.customAttributeUrls.push(request.url)
|
|
150
|
+
return HttpResponse.json({ attributes })
|
|
151
|
+
},
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
return fixture
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
describe('MedusaProvider.getAvailableFilters — facet truthfulness', () => {
|
|
158
|
+
it('emits no price range when the backend supplies no price metadata (never 0..10000)', async () => {
|
|
159
|
+
installFixture()
|
|
160
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
161
|
+
useAdvancedSearch: true,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// The call succeeded against the real backend: basic filters are present.
|
|
165
|
+
expect(filters.map((f) => f.id)).toContain('categories')
|
|
166
|
+
// No hardcoded price filter and no synthetic range anywhere.
|
|
167
|
+
expect(filters.find((f) => f.id === 'price')).toBeUndefined()
|
|
168
|
+
expect(
|
|
169
|
+
filters.some((f) => f.type === 'range' && f.min === 0 && f.max === 10000),
|
|
170
|
+
).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('does not offer the stock toggle when the optional endpoint is absent (a capability boolean is not proof)', async () => {
|
|
174
|
+
installFixture()
|
|
175
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
176
|
+
useAdvancedSearch: true,
|
|
177
|
+
})
|
|
178
|
+
// `advancedSearch: true` is advertised by the backend, but the real
|
|
179
|
+
// `/store/search` endpoint returns 500, so `inStock` is held and no toggle
|
|
180
|
+
// is offered (E2 task 2.4).
|
|
181
|
+
expect(filters.find((f) => f.id === 'isAvailable')).toBeUndefined()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('offers the stock toggle only when the optional endpoint demonstrates inStock', async () => {
|
|
185
|
+
installFixture()
|
|
186
|
+
server.use(
|
|
187
|
+
http.get(`${BASE}/store/search`, () =>
|
|
188
|
+
HttpResponse.json({ hits: [], applied_filters: { isAvailable: true } }),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
192
|
+
useAdvancedSearch: true,
|
|
193
|
+
})
|
|
194
|
+
expect(filters.find((f) => f.id === 'isAvailable')).toMatchObject({
|
|
195
|
+
type: 'boolean',
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('returns basic category/collection options with no synthesized counts', async () => {
|
|
200
|
+
installFixture()
|
|
201
|
+
const filters = await makeProvider().getAvailableFilters({})
|
|
202
|
+
|
|
203
|
+
const categories = filters.find((f) => f.id === 'categories')
|
|
204
|
+
expect(categories?.options).toEqual([
|
|
205
|
+
{ value: 'x', label: 'Category X', id: 'pcat_x' },
|
|
206
|
+
])
|
|
207
|
+
expect(categories?.options?.[0]).not.toHaveProperty('count')
|
|
208
|
+
|
|
209
|
+
const collections = filters.find((f) => f.id === 'collections')
|
|
210
|
+
expect(collections?.options?.[0]).not.toHaveProperty('count')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('omits attribute counts when the backend supplies none', async () => {
|
|
214
|
+
installFixture([MULTI_WITHOUT_COUNTS])
|
|
215
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
216
|
+
categoryId: 'pcat_x',
|
|
217
|
+
includeBasicFilters: false,
|
|
218
|
+
includeFacetCounts: true,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const ram = filters.find((f) => f.id === 'ram')
|
|
222
|
+
expect(ram?.options).toHaveLength(2)
|
|
223
|
+
expect(ram?.options?.[0]).not.toHaveProperty('count')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('emits attribute counts only from real backend-supplied numbers', async () => {
|
|
227
|
+
installFixture([MULTI_WITH_COUNTS])
|
|
228
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
229
|
+
categoryId: 'pcat_x',
|
|
230
|
+
includeBasicFilters: false,
|
|
231
|
+
includeFacetCounts: true,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const ram = filters.find((f) => f.id === 'ram')
|
|
235
|
+
expect(ram?.options).toEqual([
|
|
236
|
+
{ value: '8gb', label: '8GB', id: 'val_8', count: 15 },
|
|
237
|
+
{ value: '16gb', label: '16GB', id: 'val_16', count: 23 },
|
|
238
|
+
])
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('omits attribute counts when the caller did not request them', async () => {
|
|
242
|
+
installFixture([MULTI_WITH_COUNTS])
|
|
243
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
244
|
+
categoryId: 'pcat_x',
|
|
245
|
+
includeBasicFilters: false,
|
|
246
|
+
includeFacetCounts: false,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const ram = filters.find((f) => f.id === 'ram')
|
|
250
|
+
expect(ram?.options?.[0]).not.toHaveProperty('count')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('emits an attribute range only from real backend bounds', async () => {
|
|
254
|
+
installFixture([RANGE_WITH_BOUNDS])
|
|
255
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
256
|
+
categoryId: 'pcat_x',
|
|
257
|
+
includeBasicFilters: false,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
expect(filters.find((f) => f.id === 'price-attr')).toMatchObject({
|
|
261
|
+
type: 'range',
|
|
262
|
+
min: 5,
|
|
263
|
+
max: 250,
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('does not fabricate a 0/100 range when metadata bounds are missing', async () => {
|
|
268
|
+
installFixture([RANGE_WITHOUT_BOUNDS])
|
|
269
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
270
|
+
categoryId: 'pcat_x',
|
|
271
|
+
includeBasicFilters: false,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// No fabricated range control at all.
|
|
275
|
+
expect(filters.find((f) => f.id === 'price-attr')).toBeUndefined()
|
|
276
|
+
expect(
|
|
277
|
+
filters.some((f) => f.type === 'range' && f.min === 0 && f.max === 100),
|
|
278
|
+
).toBe(false)
|
|
279
|
+
})
|
|
280
|
+
})
|