@doswiftly/storefront-sdk 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +430 -0
- package/dist/__tests__/unit/test-helpers.d.ts +46 -0
- package/dist/__tests__/unit/test-helpers.d.ts.map +1 -0
- package/dist/__tests__/unit/test-helpers.js +72 -0
- package/dist/core/auth/auth-client.d.ts +46 -0
- package/dist/core/auth/auth-client.d.ts.map +1 -0
- package/dist/core/auth/auth-client.js +82 -0
- package/dist/core/auth/cookie-config.d.ts +18 -0
- package/dist/core/auth/cookie-config.d.ts.map +1 -0
- package/dist/core/auth/cookie-config.js +18 -0
- package/dist/core/auth/handlers.d.ts +32 -0
- package/dist/core/auth/handlers.d.ts.map +1 -0
- package/dist/core/auth/handlers.js +127 -0
- package/dist/core/auth/routes.d.ts +21 -0
- package/dist/core/auth/routes.d.ts.map +1 -0
- package/dist/core/auth/routes.js +14 -0
- package/dist/core/auth/token-client.d.ts +26 -0
- package/dist/core/auth/token-client.d.ts.map +1 -0
- package/dist/core/auth/token-client.js +42 -0
- package/dist/core/auth/types.d.ts +53 -0
- package/dist/core/auth/types.d.ts.map +1 -0
- package/dist/core/auth/types.js +4 -0
- package/dist/core/cache.d.ts +54 -0
- package/dist/core/cache.d.ts.map +1 -0
- package/dist/core/cache.js +82 -0
- package/dist/core/cart/cart-client.d.ts +57 -0
- package/dist/core/cart/cart-client.d.ts.map +1 -0
- package/dist/core/cart/cart-client.js +89 -0
- package/dist/core/cart/types.d.ts +110 -0
- package/dist/core/cart/types.d.ts.map +1 -0
- package/dist/core/cart/types.js +6 -0
- package/dist/core/client/compose.d.ts +9 -0
- package/dist/core/client/compose.d.ts.map +1 -0
- package/dist/core/client/compose.js +9 -0
- package/dist/core/client/create-client.d.ts +15 -0
- package/dist/core/client/create-client.d.ts.map +1 -0
- package/dist/core/client/create-client.js +85 -0
- package/dist/core/client/dedupe.d.ts +7 -0
- package/dist/core/client/dedupe.d.ts.map +1 -0
- package/dist/core/client/dedupe.js +16 -0
- package/dist/core/client/execute.d.ts +20 -0
- package/dist/core/client/execute.d.ts.map +1 -0
- package/dist/core/client/execute.js +48 -0
- package/dist/core/client/hash.d.ts +7 -0
- package/dist/core/client/hash.d.ts.map +1 -0
- package/dist/core/client/hash.js +21 -0
- package/dist/core/client/operation-name.d.ts +7 -0
- package/dist/core/client/operation-name.d.ts.map +1 -0
- package/dist/core/client/operation-name.js +10 -0
- package/dist/core/client/types.d.ts +126 -0
- package/dist/core/client/types.d.ts.map +1 -0
- package/dist/core/client/types.js +26 -0
- package/dist/core/errors.d.ts +43 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +43 -0
- package/dist/core/format.d.ts +92 -0
- package/dist/core/format.d.ts.map +1 -0
- package/dist/core/format.js +216 -0
- package/dist/core/helpers/assert-no-user-errors.d.ts +10 -0
- package/dist/core/helpers/assert-no-user-errors.d.ts.map +1 -0
- package/dist/core/helpers/assert-no-user-errors.js +16 -0
- package/dist/core/helpers/normalize-connection.d.ts +36 -0
- package/dist/core/helpers/normalize-connection.d.ts.map +1 -0
- package/dist/core/helpers/normalize-connection.js +21 -0
- package/dist/core/helpers/sanitize-html.d.ts +8 -0
- package/dist/core/helpers/sanitize-html.d.ts.map +1 -0
- package/dist/core/helpers/sanitize-html.js +35 -0
- package/dist/core/index.d.ts +59 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +68 -0
- package/dist/core/middleware/auth.d.ts +16 -0
- package/dist/core/middleware/auth.d.ts.map +1 -0
- package/dist/core/middleware/auth.js +22 -0
- package/dist/core/middleware/currency.d.ts +15 -0
- package/dist/core/middleware/currency.d.ts.map +1 -0
- package/dist/core/middleware/currency.js +21 -0
- package/dist/core/middleware/errors.d.ts +24 -0
- package/dist/core/middleware/errors.d.ts.map +1 -0
- package/dist/core/middleware/errors.js +77 -0
- package/dist/core/middleware/retry.d.ts +22 -0
- package/dist/core/middleware/retry.d.ts.map +1 -0
- package/dist/core/middleware/retry.js +58 -0
- package/dist/core/middleware/timeout.d.ts +19 -0
- package/dist/core/middleware/timeout.d.ts.map +1 -0
- package/dist/core/middleware/timeout.js +51 -0
- package/dist/core/operations/auth.d.ts +11 -0
- package/dist/core/operations/auth.d.ts.map +1 -0
- package/dist/core/operations/auth.js +112 -0
- package/dist/core/operations/cart.d.ts +15 -0
- package/dist/core/operations/cart.d.ts.map +1 -0
- package/dist/core/operations/cart.js +169 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/react/cookies.d.ts +28 -0
- package/dist/react/cookies.d.ts.map +1 -0
- package/dist/react/cookies.js +49 -0
- package/dist/react/helpers/create-store-context.d.ts +37 -0
- package/dist/react/helpers/create-store-context.d.ts.map +1 -0
- package/dist/react/helpers/create-store-context.js +47 -0
- package/dist/react/hooks/use-auth.d.ts +65 -0
- package/dist/react/hooks/use-auth.d.ts.map +1 -0
- package/dist/react/hooks/use-auth.js +168 -0
- package/dist/react/hooks/use-cart-manager.d.ts +30 -0
- package/dist/react/hooks/use-cart-manager.d.ts.map +1 -0
- package/dist/react/hooks/use-cart-manager.js +223 -0
- package/dist/react/hooks/use-currency.d.ts +11 -0
- package/dist/react/hooks/use-currency.d.ts.map +1 -0
- package/dist/react/hooks/use-currency.js +19 -0
- package/dist/react/hooks/use-debounced-value.d.ts +15 -0
- package/dist/react/hooks/use-debounced-value.d.ts.map +1 -0
- package/dist/react/hooks/use-debounced-value.js +25 -0
- package/dist/react/hooks/use-hydrated.d.ts +9 -0
- package/dist/react/hooks/use-hydrated.d.ts.map +1 -0
- package/dist/react/hooks/use-hydrated.js +14 -0
- package/dist/react/hooks/use-storefront-client.d.ts +6 -0
- package/dist/react/hooks/use-storefront-client.d.ts.map +1 -0
- package/dist/react/hooks/use-storefront-client.js +8 -0
- package/dist/react/index.d.ts +30 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +34 -0
- package/dist/react/providers/currency-provider.d.ts +14 -0
- package/dist/react/providers/currency-provider.d.ts.map +1 -0
- package/dist/react/providers/currency-provider.js +20 -0
- package/dist/react/providers/storefront-client-provider.d.ts +33 -0
- package/dist/react/providers/storefront-client-provider.d.ts.map +1 -0
- package/dist/react/providers/storefront-client-provider.js +57 -0
- package/dist/react/providers/storefront-provider.d.ts +42 -0
- package/dist/react/providers/storefront-provider.d.ts.map +1 -0
- package/dist/react/providers/storefront-provider.js +40 -0
- package/dist/react/server/get-storefront-client.d.ts +42 -0
- package/dist/react/server/get-storefront-client.d.ts.map +1 -0
- package/dist/react/server/get-storefront-client.js +44 -0
- package/dist/react/server/index.d.ts +2 -0
- package/dist/react/server/index.d.ts.map +1 -0
- package/dist/react/server/index.js +1 -0
- package/dist/react/stores/auth.store.d.ts +48 -0
- package/dist/react/stores/auth.store.d.ts.map +1 -0
- package/dist/react/stores/auth.store.js +67 -0
- package/dist/react/stores/currency.store.d.ts +29 -0
- package/dist/react/stores/currency.store.d.ts.map +1 -0
- package/dist/react/stores/currency.store.js +76 -0
- package/dist/react/stores/index.d.ts +8 -0
- package/dist/react/stores/index.d.ts.map +1 -0
- package/dist/react/stores/index.js +10 -0
- package/dist/react/stores/store-context.d.ts +27 -0
- package/dist/react/stores/store-context.d.ts.map +1 -0
- package/dist/react/stores/store-context.js +62 -0
- package/package.json +71 -0
- package/src/__tests__/contract/storefront-api.contract.test.ts +450 -0
- package/src/__tests__/unit/auth-client.test.ts +210 -0
- package/src/__tests__/unit/cart-client.test.ts +233 -0
- package/src/__tests__/unit/create-client.test.ts +356 -0
- package/src/__tests__/unit/helpers.test.ts +377 -0
- package/src/__tests__/unit/middleware.test.ts +374 -0
- package/src/__tests__/unit/test-helpers.ts +103 -0
- package/src/core/auth/auth-client.ts +123 -0
- package/src/core/auth/cookie-config.ts +23 -0
- package/src/core/auth/handlers.ts +168 -0
- package/src/core/auth/routes.ts +26 -0
- package/src/core/auth/token-client.ts +51 -0
- package/src/core/auth/types.ts +54 -0
- package/src/core/cache.ts +102 -0
- package/src/core/cart/cart-client.ts +150 -0
- package/src/core/cart/types.ts +104 -0
- package/src/core/client/compose.ts +15 -0
- package/src/core/client/create-client.ts +129 -0
- package/src/core/client/dedupe.ts +19 -0
- package/src/core/client/execute.ts +70 -0
- package/src/core/client/hash.ts +21 -0
- package/src/core/client/operation-name.ts +12 -0
- package/src/core/client/types.ts +171 -0
- package/src/core/errors.ts +67 -0
- package/src/core/format.ts +254 -0
- package/src/core/helpers/assert-no-user-errors.ts +21 -0
- package/src/core/helpers/normalize-connection.ts +48 -0
- package/src/core/helpers/sanitize-html.ts +42 -0
- package/src/core/index.ts +148 -0
- package/src/core/middleware/auth.ts +27 -0
- package/src/core/middleware/currency.ts +26 -0
- package/src/core/middleware/errors.ts +86 -0
- package/src/core/middleware/retry.ts +75 -0
- package/src/core/middleware/timeout.ts +61 -0
- package/src/core/operations/auth.ts +123 -0
- package/src/core/operations/cart.ts +185 -0
- package/src/index.ts +25 -0
- package/src/react/cookies.ts +54 -0
- package/src/react/helpers/create-store-context.ts +56 -0
- package/src/react/hooks/use-auth.ts +218 -0
- package/src/react/hooks/use-cart-manager.ts +236 -0
- package/src/react/hooks/use-currency.ts +23 -0
- package/src/react/hooks/use-debounced-value.ts +30 -0
- package/src/react/hooks/use-hydrated.ts +20 -0
- package/src/react/hooks/use-storefront-client.ts +12 -0
- package/src/react/index.ts +45 -0
- package/src/react/providers/currency-provider.tsx +30 -0
- package/src/react/providers/storefront-client-provider.tsx +90 -0
- package/src/react/providers/storefront-provider.tsx +71 -0
- package/src/react/server/get-storefront-client.ts +60 -0
- package/src/react/server/index.ts +1 -0
- package/src/react/stores/auth.store.ts +112 -0
- package/src/react/stores/currency.store.ts +113 -0
- package/src/react/stores/index.ts +17 -0
- package/src/react/stores/store-context.tsx +82 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract Tests for Storefront GraphQL API (v4 SDK).
|
|
3
|
+
*
|
|
4
|
+
* These tests validate that the backend API responses match expected
|
|
5
|
+
* shapes when accessed through the v4 SDK (createStorefrontClient,
|
|
6
|
+
* CartClient, AuthClient).
|
|
7
|
+
*
|
|
8
|
+
* REQUIRES: Running backend at STOREFRONT_API_URL.
|
|
9
|
+
*
|
|
10
|
+
* Run: pnpm run test:contract
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
14
|
+
import { createStorefrontClient } from '../../core/client/create-client';
|
|
15
|
+
import { errorMiddleware } from '../../core/middleware/errors';
|
|
16
|
+
import { CartClient } from '../../core/cart/cart-client';
|
|
17
|
+
import { AuthClient } from '../../core/auth/auth-client';
|
|
18
|
+
import { StorefrontError, ErrorCodes } from '../../core/errors';
|
|
19
|
+
import type { StorefrontClient } from '../../core/client/types';
|
|
20
|
+
|
|
21
|
+
// ============================================
|
|
22
|
+
// Configuration
|
|
23
|
+
// ============================================
|
|
24
|
+
|
|
25
|
+
const API_URL = process.env.STOREFRONT_API_URL || 'http://localhost:8000';
|
|
26
|
+
const SHOP_SLUG = process.env.TEST_SHOP_SLUG || 'test-shop';
|
|
27
|
+
|
|
28
|
+
const PRODUCT_QUERY = `
|
|
29
|
+
query Product($handle: String!) {
|
|
30
|
+
product(handle: $handle) {
|
|
31
|
+
id handle title description
|
|
32
|
+
variants { id title sku quantityAvailable }
|
|
33
|
+
priceRange { minVariantPrice { amount currencyCode } }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const PRODUCTS_QUERY = `
|
|
39
|
+
query Products($first: Int!) {
|
|
40
|
+
products(first: $first) {
|
|
41
|
+
edges { node { id handle title } }
|
|
42
|
+
pageInfo { hasNextPage endCursor }
|
|
43
|
+
totalCount
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const SHOP_QUERY = `
|
|
49
|
+
query Shop {
|
|
50
|
+
shop { id name currencyCode supportedCurrencies }
|
|
51
|
+
}
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const CATEGORIES_QUERY = `
|
|
55
|
+
query Categories {
|
|
56
|
+
categories { roots { id slug name } totalCount }
|
|
57
|
+
}
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const COLLECTIONS_QUERY = `
|
|
61
|
+
query Collections($first: Int!) {
|
|
62
|
+
collections(first: $first) {
|
|
63
|
+
edges { node { id handle title } }
|
|
64
|
+
pageInfo { hasNextPage endCursor }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
// ============================================
|
|
70
|
+
// Setup
|
|
71
|
+
// ============================================
|
|
72
|
+
|
|
73
|
+
describe('Storefront API Contract Tests (v4 SDK)', () => {
|
|
74
|
+
let client: StorefrontClient;
|
|
75
|
+
let cartClient: CartClient;
|
|
76
|
+
let authClient: AuthClient;
|
|
77
|
+
let variantId: string | undefined;
|
|
78
|
+
|
|
79
|
+
beforeAll(async () => {
|
|
80
|
+
client = createStorefrontClient({
|
|
81
|
+
apiUrl: API_URL,
|
|
82
|
+
shopSlug: SHOP_SLUG,
|
|
83
|
+
middleware: [errorMiddleware()],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
cartClient = new CartClient(client);
|
|
87
|
+
authClient = new AuthClient(client);
|
|
88
|
+
|
|
89
|
+
// Get a variant ID for cart tests
|
|
90
|
+
try {
|
|
91
|
+
const data = await client.query<{
|
|
92
|
+
products: { edges: Array<{ node: { id: string; handle: string } }> };
|
|
93
|
+
}>(PRODUCTS_QUERY, { first: 1 });
|
|
94
|
+
|
|
95
|
+
if (data.products.edges.length > 0) {
|
|
96
|
+
const handle = data.products.edges[0].node.handle;
|
|
97
|
+
const productData = await client.query<{
|
|
98
|
+
product: { variants: Array<{ id: string; quantityAvailable: number }> } | null;
|
|
99
|
+
}>(PRODUCT_QUERY, { handle });
|
|
100
|
+
|
|
101
|
+
const variant = productData.product?.variants?.[0];
|
|
102
|
+
if (variant && variant.quantityAvailable > 0) {
|
|
103
|
+
variantId = variant.id;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch (e) {
|
|
107
|
+
console.warn('Could not fetch variant for cart tests:', e);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ============================================
|
|
112
|
+
// Query Contract Tests
|
|
113
|
+
// ============================================
|
|
114
|
+
|
|
115
|
+
describe('Queries', () => {
|
|
116
|
+
it('Shop — should return shop info with currency', async () => {
|
|
117
|
+
const data = await client.query<{
|
|
118
|
+
shop: { id: string; name: string; currencyCode: string; supportedCurrencies: string[] };
|
|
119
|
+
}>(SHOP_QUERY);
|
|
120
|
+
|
|
121
|
+
expect(data.shop).toBeDefined();
|
|
122
|
+
expect(data.shop.id).toBeDefined();
|
|
123
|
+
expect(data.shop.name).toBeDefined();
|
|
124
|
+
expect(data.shop.currencyCode).toBeDefined();
|
|
125
|
+
expect(Array.isArray(data.shop.supportedCurrencies)).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('Product — should return product by handle', async () => {
|
|
129
|
+
const data = await client.query<{
|
|
130
|
+
product: { id: string; handle: string; title: string } | null;
|
|
131
|
+
}>(PRODUCT_QUERY, { handle: 'test-product' });
|
|
132
|
+
|
|
133
|
+
// Product may or may not exist — just validate shape
|
|
134
|
+
if (data.product) {
|
|
135
|
+
expect(data.product.id).toBeDefined();
|
|
136
|
+
expect(data.product.handle).toBeDefined();
|
|
137
|
+
expect(data.product.title).toBeDefined();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('Product — should return null for non-existent handle', async () => {
|
|
142
|
+
const data = await client.query<{
|
|
143
|
+
product: { id: string } | null;
|
|
144
|
+
}>(PRODUCT_QUERY, { handle: 'definitely-non-existent-product-xyz-12345' });
|
|
145
|
+
|
|
146
|
+
expect(data.product).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('Products — should return paginated list', async () => {
|
|
150
|
+
const data = await client.query<{
|
|
151
|
+
products: {
|
|
152
|
+
edges: Array<{ node: { id: string } }>;
|
|
153
|
+
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
|
154
|
+
totalCount: number;
|
|
155
|
+
};
|
|
156
|
+
}>(PRODUCTS_QUERY, { first: 5 });
|
|
157
|
+
|
|
158
|
+
expect(data.products).toBeDefined();
|
|
159
|
+
expect(Array.isArray(data.products.edges)).toBe(true);
|
|
160
|
+
expect(data.products.pageInfo).toBeDefined();
|
|
161
|
+
expect(typeof data.products.pageInfo.hasNextPage).toBe('boolean');
|
|
162
|
+
expect(typeof data.products.totalCount).toBe('number');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('Products — should support cursor pagination', async () => {
|
|
166
|
+
const page1 = await client.query<{
|
|
167
|
+
products: {
|
|
168
|
+
edges: Array<{ node: { id: string } }>;
|
|
169
|
+
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
|
170
|
+
};
|
|
171
|
+
}>(PRODUCTS_QUERY, { first: 2 });
|
|
172
|
+
|
|
173
|
+
if (page1.products.pageInfo.hasNextPage && page1.products.pageInfo.endCursor) {
|
|
174
|
+
const PRODUCTS_AFTER = `
|
|
175
|
+
query ProductsAfter($first: Int!, $after: String) {
|
|
176
|
+
products(first: $first, after: $after) {
|
|
177
|
+
edges { node { id } }
|
|
178
|
+
pageInfo { hasNextPage endCursor }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
`;
|
|
182
|
+
|
|
183
|
+
const page2 = await client.query<{
|
|
184
|
+
products: { edges: Array<{ node: { id: string } }> };
|
|
185
|
+
}>(PRODUCTS_AFTER, { first: 2, after: page1.products.pageInfo.endCursor });
|
|
186
|
+
|
|
187
|
+
expect(page2.products.edges).toBeDefined();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('Categories — should return category tree', async () => {
|
|
192
|
+
const data = await client.query<{
|
|
193
|
+
categories: { roots: Array<{ id: string; slug: string; name: string }>; totalCount: number };
|
|
194
|
+
}>(CATEGORIES_QUERY);
|
|
195
|
+
|
|
196
|
+
expect(data.categories).toBeDefined();
|
|
197
|
+
expect(Array.isArray(data.categories.roots)).toBe(true);
|
|
198
|
+
expect(typeof data.categories.totalCount).toBe('number');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('Collections — should return paginated list', async () => {
|
|
202
|
+
const data = await client.query<{
|
|
203
|
+
collections: {
|
|
204
|
+
edges: Array<{ node: { id: string } }>;
|
|
205
|
+
pageInfo: { hasNextPage: boolean };
|
|
206
|
+
};
|
|
207
|
+
}>(COLLECTIONS_QUERY, { first: 5 });
|
|
208
|
+
|
|
209
|
+
expect(data.collections).toBeDefined();
|
|
210
|
+
expect(Array.isArray(data.collections.edges)).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ============================================
|
|
215
|
+
// CartClient Contract Tests
|
|
216
|
+
// ============================================
|
|
217
|
+
|
|
218
|
+
describe('CartClient', () => {
|
|
219
|
+
it('should create empty cart', async () => {
|
|
220
|
+
const cart = await cartClient.create();
|
|
221
|
+
|
|
222
|
+
expect(cart.id).toBeDefined();
|
|
223
|
+
expect(cart.totalQuantity).toBe(0);
|
|
224
|
+
expect(cart.lines).toHaveLength(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should get existing cart by ID', async () => {
|
|
228
|
+
const created = await cartClient.create();
|
|
229
|
+
const fetched = await cartClient.get(created.id);
|
|
230
|
+
|
|
231
|
+
expect(fetched).not.toBeNull();
|
|
232
|
+
expect(fetched!.id).toBe(created.id);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should return null for non-existent cart', async () => {
|
|
236
|
+
const cart = await cartClient.get('non-existent-cart-id');
|
|
237
|
+
expect(cart).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should complete full cart lifecycle', async () => {
|
|
241
|
+
if (!variantId) {
|
|
242
|
+
console.warn('Skipping cart lifecycle — no variant with stock');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 1. Create
|
|
247
|
+
const cart = await cartClient.create();
|
|
248
|
+
expect(cart.id).toBeDefined();
|
|
249
|
+
|
|
250
|
+
// 2. Add item
|
|
251
|
+
const afterAdd = await cartClient.addItems(cart.id, [
|
|
252
|
+
{ merchandiseId: variantId, quantity: 1 },
|
|
253
|
+
]);
|
|
254
|
+
expect(afterAdd.lines).toHaveLength(1);
|
|
255
|
+
expect(afterAdd.totalQuantity).toBe(1);
|
|
256
|
+
|
|
257
|
+
const lineId = afterAdd.lines[0].id;
|
|
258
|
+
|
|
259
|
+
// 3. Update quantity
|
|
260
|
+
const afterUpdate = await cartClient.updateItems(cart.id, [
|
|
261
|
+
{ id: lineId, quantity: 2 },
|
|
262
|
+
]);
|
|
263
|
+
expect(afterUpdate.lines[0].quantity).toBe(2);
|
|
264
|
+
|
|
265
|
+
// 4. Remove
|
|
266
|
+
const afterRemove = await cartClient.removeItems(cart.id, [lineId]);
|
|
267
|
+
expect(afterRemove.lines).toHaveLength(0);
|
|
268
|
+
expect(afterRemove.totalQuantity).toBe(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should update cart note', async () => {
|
|
272
|
+
const cart = await cartClient.create();
|
|
273
|
+
const updated = await cartClient.updateNote(cart.id, 'Gift wrap please');
|
|
274
|
+
|
|
275
|
+
expect(updated.note).toBe('Gift wrap please');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should handle discount codes', async () => {
|
|
279
|
+
const cart = await cartClient.create();
|
|
280
|
+
|
|
281
|
+
// Apply (may fail if code doesn't exist — that's OK, we test the API shape)
|
|
282
|
+
try {
|
|
283
|
+
const updated = await cartClient.updateDiscountCodes(cart.id, ['TESTCODE10']);
|
|
284
|
+
expect(updated).toBeDefined();
|
|
285
|
+
} catch (err) {
|
|
286
|
+
// User errors are expected if discount code is invalid
|
|
287
|
+
if (err instanceof StorefrontError) {
|
|
288
|
+
expect(err.code).toBe(ErrorCodes.USER_ERROR);
|
|
289
|
+
} else {
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ============================================
|
|
297
|
+
// AuthClient Contract Tests
|
|
298
|
+
// ============================================
|
|
299
|
+
|
|
300
|
+
describe('AuthClient', () => {
|
|
301
|
+
const testEmail = `contract-v4-${Date.now()}@example.com`;
|
|
302
|
+
const testPassword = 'ContractTest123!';
|
|
303
|
+
|
|
304
|
+
it('should register new customer', async () => {
|
|
305
|
+
const result = await authClient.register({
|
|
306
|
+
email: testEmail,
|
|
307
|
+
password: testPassword,
|
|
308
|
+
firstName: 'Contract',
|
|
309
|
+
lastName: 'Test',
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(result.accessToken).toBeDefined();
|
|
313
|
+
expect(result.expiresAt).toBeDefined();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should throw on duplicate registration', async () => {
|
|
317
|
+
try {
|
|
318
|
+
await authClient.register({
|
|
319
|
+
email: testEmail,
|
|
320
|
+
password: testPassword,
|
|
321
|
+
});
|
|
322
|
+
expect.fail('Should have thrown on duplicate email');
|
|
323
|
+
} catch (err) {
|
|
324
|
+
expect(err).toBeInstanceOf(StorefrontError);
|
|
325
|
+
expect((err as StorefrontError).code).toBe(ErrorCodes.USER_ERROR);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should login with valid credentials', async () => {
|
|
330
|
+
const result = await authClient.login(testEmail, testPassword);
|
|
331
|
+
|
|
332
|
+
expect(result.accessToken).toBeDefined();
|
|
333
|
+
expect(result.expiresAt).toBeDefined();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should throw on invalid credentials', async () => {
|
|
337
|
+
try {
|
|
338
|
+
await authClient.login('nonexistent@example.com', 'WrongPassword123!');
|
|
339
|
+
expect.fail('Should have thrown');
|
|
340
|
+
} catch (err) {
|
|
341
|
+
expect(err).toBeInstanceOf(StorefrontError);
|
|
342
|
+
expect((err as StorefrontError).code).toBe(ErrorCodes.USER_ERROR);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should fetch customer profile', async () => {
|
|
347
|
+
const { accessToken } = await authClient.login(testEmail, testPassword);
|
|
348
|
+
|
|
349
|
+
// Create authenticated client
|
|
350
|
+
const authSfClient = createStorefrontClient({
|
|
351
|
+
apiUrl: API_URL,
|
|
352
|
+
shopSlug: SHOP_SLUG,
|
|
353
|
+
middleware: [errorMiddleware()],
|
|
354
|
+
defaultHeaders: { 'X-Customer-Token': accessToken },
|
|
355
|
+
});
|
|
356
|
+
const authCustomerClient = new AuthClient(authSfClient);
|
|
357
|
+
|
|
358
|
+
const customer = await authCustomerClient.getCustomer(accessToken);
|
|
359
|
+
|
|
360
|
+
expect(customer).not.toBeNull();
|
|
361
|
+
expect(customer!.email).toBe(testEmail);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should renew token', async () => {
|
|
365
|
+
const { accessToken } = await authClient.login(testEmail, testPassword);
|
|
366
|
+
|
|
367
|
+
const renewed = await authClient.renewToken(accessToken);
|
|
368
|
+
|
|
369
|
+
expect(renewed.accessToken).toBeDefined();
|
|
370
|
+
expect(renewed.expiresAt).toBeDefined();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should logout without error', async () => {
|
|
374
|
+
const { accessToken } = await authClient.login(testEmail, testPassword);
|
|
375
|
+
|
|
376
|
+
// Should not throw
|
|
377
|
+
await authClient.logout(accessToken);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should handle logout with expired token gracefully', async () => {
|
|
381
|
+
// Should not throw
|
|
382
|
+
await authClient.logout('definitely-expired-token');
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ============================================
|
|
387
|
+
// Error Handling Contract Tests
|
|
388
|
+
// ============================================
|
|
389
|
+
|
|
390
|
+
describe('Error Handling', () => {
|
|
391
|
+
it('should throw StorefrontError for invalid GraphQL', async () => {
|
|
392
|
+
try {
|
|
393
|
+
await client.query('{ invalidField { nope } }');
|
|
394
|
+
expect.fail('Should have thrown');
|
|
395
|
+
} catch (err) {
|
|
396
|
+
expect(err).toBeInstanceOf(StorefrontError);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should handle product not found as null, not error', async () => {
|
|
401
|
+
const data = await client.query<{ product: unknown | null }>(
|
|
402
|
+
PRODUCT_QUERY,
|
|
403
|
+
{ handle: 'absolutely-nonexistent-product-12345' },
|
|
404
|
+
);
|
|
405
|
+
expect(data.product).toBeNull();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ============================================
|
|
410
|
+
// Middleware Integration Contract Tests
|
|
411
|
+
// ============================================
|
|
412
|
+
|
|
413
|
+
describe('Middleware Integration', () => {
|
|
414
|
+
it('should pass custom headers through middleware', async () => {
|
|
415
|
+
const clientWithCurrency = createStorefrontClient({
|
|
416
|
+
apiUrl: API_URL,
|
|
417
|
+
shopSlug: SHOP_SLUG,
|
|
418
|
+
middleware: [
|
|
419
|
+
(req, next) => {
|
|
420
|
+
req.headers['X-Preferred-Currency'] = 'EUR';
|
|
421
|
+
return next(req);
|
|
422
|
+
},
|
|
423
|
+
errorMiddleware(),
|
|
424
|
+
],
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Should not throw — backend accepts the header
|
|
428
|
+
const data = await clientWithCurrency.query<{
|
|
429
|
+
shop: { currencyCode: string };
|
|
430
|
+
}>(SHOP_QUERY);
|
|
431
|
+
|
|
432
|
+
expect(data.shop).toBeDefined();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should support imperative use() API', async () => {
|
|
436
|
+
const freshClient = createStorefrontClient({
|
|
437
|
+
apiUrl: API_URL,
|
|
438
|
+
shopSlug: SHOP_SLUG,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
freshClient.use(errorMiddleware());
|
|
442
|
+
|
|
443
|
+
const data = await freshClient.query<{
|
|
444
|
+
shop: { id: string };
|
|
445
|
+
}>(SHOP_QUERY);
|
|
446
|
+
|
|
447
|
+
expect(data.shop.id).toBeDefined();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for AuthClient — plain async auth API.
|
|
3
|
+
*
|
|
4
|
+
* Tests login, logout, renewToken, register, getCustomer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
+
import { AuthClient } from '../../core/auth/auth-client';
|
|
9
|
+
import { StorefrontError, ErrorCodes } from '../../core/errors';
|
|
10
|
+
import type { StorefrontClient } from '../../core/client/types';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Mock StorefrontClient
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
function createMockClient(responses: Record<string, unknown>): StorefrontClient {
|
|
17
|
+
return {
|
|
18
|
+
query: vi.fn(async () => responses),
|
|
19
|
+
mutate: vi.fn(async () => responses),
|
|
20
|
+
use: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Tests
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
describe('AuthClient', () => {
|
|
29
|
+
describe('login()', () => {
|
|
30
|
+
it('should return accessToken and expiresAt on success', async () => {
|
|
31
|
+
const client = createMockClient({
|
|
32
|
+
customerAccessTokenCreate: {
|
|
33
|
+
customerAccessToken: {
|
|
34
|
+
accessToken: 'token-123',
|
|
35
|
+
expiresAt: '2026-04-01T00:00:00Z',
|
|
36
|
+
},
|
|
37
|
+
userErrors: [],
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const authClient = new AuthClient(client);
|
|
41
|
+
|
|
42
|
+
const result = await authClient.login('user@example.com', 'password');
|
|
43
|
+
|
|
44
|
+
expect(result.accessToken).toBe('token-123');
|
|
45
|
+
expect(result.expiresAt).toBe('2026-04-01T00:00:00Z');
|
|
46
|
+
expect(client.mutate).toHaveBeenCalledWith(
|
|
47
|
+
expect.any(String),
|
|
48
|
+
{ input: { email: 'user@example.com', password: 'password' } },
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should throw StorefrontError on invalid credentials', async () => {
|
|
53
|
+
const client = createMockClient({
|
|
54
|
+
customerAccessTokenCreate: {
|
|
55
|
+
customerAccessToken: null,
|
|
56
|
+
userErrors: [{ message: 'Invalid email or password' }],
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const authClient = new AuthClient(client);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await authClient.login('bad@example.com', 'wrong');
|
|
63
|
+
expect.fail('Should have thrown');
|
|
64
|
+
} catch (err) {
|
|
65
|
+
expect(err).toBeInstanceOf(StorefrontError);
|
|
66
|
+
const sfErr = err as StorefrontError;
|
|
67
|
+
expect(sfErr.code).toBe(ErrorCodes.USER_ERROR);
|
|
68
|
+
expect(sfErr.message).toBe('Invalid email or password');
|
|
69
|
+
expect(sfErr.hasUserErrors).toBe(true);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('logout()', () => {
|
|
75
|
+
it('should call mutate with token', async () => {
|
|
76
|
+
const client = createMockClient({
|
|
77
|
+
customerAccessTokenDelete: {
|
|
78
|
+
deletedAccessToken: 'token-123',
|
|
79
|
+
userErrors: [],
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const authClient = new AuthClient(client);
|
|
83
|
+
|
|
84
|
+
await authClient.logout('token-123');
|
|
85
|
+
|
|
86
|
+
expect(client.mutate).toHaveBeenCalledWith(
|
|
87
|
+
expect.any(String),
|
|
88
|
+
{ customerAccessToken: 'token-123' },
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should NOT throw on failure (token may be expired)', async () => {
|
|
93
|
+
const client: StorefrontClient = {
|
|
94
|
+
query: vi.fn(),
|
|
95
|
+
mutate: vi.fn(async () => { throw new Error('Server error'); }),
|
|
96
|
+
use: vi.fn(),
|
|
97
|
+
};
|
|
98
|
+
const authClient = new AuthClient(client);
|
|
99
|
+
|
|
100
|
+
// Should not throw
|
|
101
|
+
await authClient.logout('expired-token');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('renewToken()', () => {
|
|
106
|
+
it('should return renewed token', async () => {
|
|
107
|
+
const client = createMockClient({
|
|
108
|
+
customerAccessTokenRenew: {
|
|
109
|
+
customerAccessToken: {
|
|
110
|
+
accessToken: 'new-token-456',
|
|
111
|
+
expiresAt: '2026-05-01T00:00:00Z',
|
|
112
|
+
},
|
|
113
|
+
userErrors: [],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
const authClient = new AuthClient(client);
|
|
117
|
+
|
|
118
|
+
const result = await authClient.renewToken('old-token');
|
|
119
|
+
|
|
120
|
+
expect(result.accessToken).toBe('new-token-456');
|
|
121
|
+
expect(result.expiresAt).toBe('2026-05-01T00:00:00Z');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should throw on user errors', async () => {
|
|
125
|
+
const client = createMockClient({
|
|
126
|
+
customerAccessTokenRenew: {
|
|
127
|
+
customerAccessToken: null,
|
|
128
|
+
userErrors: [{ message: 'Token expired' }],
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const authClient = new AuthClient(client);
|
|
132
|
+
|
|
133
|
+
await expect(authClient.renewToken('expired')).rejects.toThrow(StorefrontError);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('register()', () => {
|
|
138
|
+
it('should return token and customer on success', async () => {
|
|
139
|
+
const client = createMockClient({
|
|
140
|
+
customerCreate: {
|
|
141
|
+
customer: { id: 'cust-1', email: 'new@example.com', firstName: 'John', lastName: 'Doe' },
|
|
142
|
+
customerAccessToken: {
|
|
143
|
+
accessToken: 'reg-token',
|
|
144
|
+
expiresAt: '2026-04-01T00:00:00Z',
|
|
145
|
+
},
|
|
146
|
+
userErrors: [],
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const authClient = new AuthClient(client);
|
|
150
|
+
|
|
151
|
+
const result = await authClient.register({
|
|
152
|
+
email: 'new@example.com',
|
|
153
|
+
password: 'SecurePass123!',
|
|
154
|
+
firstName: 'John',
|
|
155
|
+
lastName: 'Doe',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(result.accessToken).toBe('reg-token');
|
|
159
|
+
expect(result.customer?.email).toBe('new@example.com');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should throw on duplicate email', async () => {
|
|
163
|
+
const client = createMockClient({
|
|
164
|
+
customerCreate: {
|
|
165
|
+
customer: null,
|
|
166
|
+
customerAccessToken: null,
|
|
167
|
+
userErrors: [{ message: 'Email already taken', field: ['email'] }],
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
const authClient = new AuthClient(client);
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await authClient.register({ email: 'existing@example.com', password: 'Pass123!' });
|
|
174
|
+
expect.fail('Should have thrown');
|
|
175
|
+
} catch (err) {
|
|
176
|
+
expect(err).toBeInstanceOf(StorefrontError);
|
|
177
|
+
expect((err as StorefrontError).userErrors[0].field).toEqual(['email']);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('getCustomer()', () => {
|
|
183
|
+
it('should return customer data', async () => {
|
|
184
|
+
const mockCustomer = {
|
|
185
|
+
id: 'cust-1',
|
|
186
|
+
email: 'user@example.com',
|
|
187
|
+
firstName: 'Jane',
|
|
188
|
+
lastName: 'Smith',
|
|
189
|
+
};
|
|
190
|
+
const client = createMockClient({ customer: mockCustomer });
|
|
191
|
+
const authClient = new AuthClient(client);
|
|
192
|
+
|
|
193
|
+
const customer = await authClient.getCustomer('token-123');
|
|
194
|
+
|
|
195
|
+
expect(customer).toEqual(mockCustomer);
|
|
196
|
+
expect(client.query).toHaveBeenCalledWith(
|
|
197
|
+
expect.any(String),
|
|
198
|
+
{ customerAccessToken: 'token-123' },
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should return null for invalid token', async () => {
|
|
203
|
+
const client = createMockClient({ customer: null });
|
|
204
|
+
const authClient = new AuthClient(client);
|
|
205
|
+
|
|
206
|
+
const customer = await authClient.getCustomer('invalid-token');
|
|
207
|
+
expect(customer).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|