Skip to content

Commit 8bde996

Browse files
author
jie-dai_sfemu
committed
Sync from monorepo
Template version: 0.4.0-alpha.2 Uses NPM packages @salesforce/storefront-next-* v0.4.0-alpha.2 Synced by: jie-dai_sfemu Monorepo SHA: 0a48183981fe4a1afa1bcc493208ad5a37472ac4 Latest change: 0a4818398 - @W-22190928 fix cart edit variant selection (#1587)
1 parent 2c79d21 commit 8bde996

3 files changed

Lines changed: 377 additions & 41 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/**
2+
* Copyright 2026 Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { describe, test, expect, vi, beforeEach } from 'vitest';
17+
import { render, screen } from '@testing-library/react';
18+
import { getTranslation } from '@salesforce/storefront-next-runtime/i18n';
19+
import { createMemoryRouter, RouterProvider } from 'react-router';
20+
import type { ShopperProducts } from '@salesforce/storefront-next-runtime/scapi';
21+
import { AllProvidersWrapper } from '@/test-utils/context-provider';
22+
import { variantProduct } from '@/components/__mocks__/master-variant-product';
23+
24+
const { t } = getTranslation();
25+
26+
type Product = ShopperProducts.schemas['Product'];
27+
28+
const mockLoad = vi.fn().mockResolvedValue(undefined);
29+
30+
interface MockFetcherState {
31+
load: typeof mockLoad;
32+
data: Product | null;
33+
state: 'idle' | 'loading';
34+
success: boolean;
35+
}
36+
37+
let fullProductFetcherState: MockFetcherState;
38+
let variantFetcherState: MockFetcherState;
39+
40+
const mockUseScapiFetcher = vi.fn((_client: string, _method: string, opts: Record<string, unknown>) => {
41+
const params = opts.params as { path: { id: string }; query: Record<string, unknown> };
42+
const hasExpand = !!params?.query?.expand;
43+
const expandValues = params?.query?.expand as string[] | undefined;
44+
const isFullProductFetcher = hasExpand && expandValues && expandValues.includes('variations');
45+
46+
if (isFullProductFetcher) {
47+
return fullProductFetcherState;
48+
}
49+
return variantFetcherState;
50+
});
51+
52+
vi.mock('@/hooks/use-scapi-fetcher', () => ({
53+
useScapiFetcher: (...args: unknown[]) => mockUseScapiFetcher(...(args as Parameters<typeof mockUseScapiFetcher>)),
54+
}));
55+
56+
// Lazy import so the mock is installed first
57+
const { CartItemModalEditContainer } = await import('./edit-container');
58+
59+
const basketProduct: Product = {
60+
id: '640188017041M',
61+
name: 'Charcoal Flat Front Athletic Fit Shadow Striped Wool Suit',
62+
variationValues: { color: 'CHARCWL', size: '040', width: 'S' },
63+
price: 299.99,
64+
currency: 'USD',
65+
};
66+
67+
function renderEditContainer(overrides: Partial<React.ComponentProps<typeof CartItemModalEditContainer>> = {}) {
68+
const props = {
69+
product: basketProduct,
70+
itemId: 'item-1',
71+
open: true,
72+
onOpenChange: vi.fn(),
73+
initialQuantity: 2,
74+
...overrides,
75+
};
76+
const router = createMemoryRouter(
77+
[
78+
{
79+
path: '/',
80+
element: (
81+
<AllProvidersWrapper>
82+
<CartItemModalEditContainer {...props} />
83+
</AllProvidersWrapper>
84+
),
85+
},
86+
],
87+
{ initialEntries: ['/'] }
88+
);
89+
return { ...render(<RouterProvider router={router} />), props };
90+
}
91+
92+
describe('CartItemModalEditContainer', () => {
93+
beforeEach(() => {
94+
vi.clearAllMocks();
95+
fullProductFetcherState = {
96+
load: mockLoad,
97+
data: null,
98+
state: 'idle' as const,
99+
success: false,
100+
};
101+
variantFetcherState = {
102+
load: mockLoad,
103+
data: null,
104+
state: 'idle' as const,
105+
success: false,
106+
};
107+
});
108+
109+
describe('full product fetcher', () => {
110+
test('calls fullProductFetcher.load when modal is open and no data yet', () => {
111+
renderEditContainer();
112+
113+
expect(mockLoad).toHaveBeenCalled();
114+
});
115+
116+
test('does not call load when modal is closed', () => {
117+
renderEditContainer({ open: false });
118+
119+
expect(mockLoad).not.toHaveBeenCalled();
120+
});
121+
122+
test('does not call load when data already exists', () => {
123+
fullProductFetcherState = {
124+
load: mockLoad,
125+
data: variantProduct,
126+
state: 'idle' as const,
127+
success: true,
128+
};
129+
renderEditContainer();
130+
131+
expect(mockLoad).not.toHaveBeenCalled();
132+
});
133+
134+
test('configures fullProductFetcher with expand params including variations', () => {
135+
renderEditContainer();
136+
137+
const fullProductCall = mockUseScapiFetcher.mock.calls.find((call) => {
138+
const params = call[2].params as {
139+
query: { expand?: string[] };
140+
};
141+
return params?.query?.expand?.includes('variations');
142+
});
143+
144+
expect(fullProductCall).toBeDefined();
145+
const params = (fullProductCall as NonNullable<typeof fullProductCall>)[2].params as {
146+
path: { id: string };
147+
query: { expand: string[]; allImages: boolean };
148+
};
149+
expect(params.path.id).toBe('640188017041M');
150+
expect(params.query.allImages).toBe(true);
151+
expect(params.query.expand).toContain('availability');
152+
expect(params.query.expand).toContain('prices');
153+
expect(params.query.expand).toContain('promotions');
154+
});
155+
});
156+
157+
describe('loading state', () => {
158+
test('shows loading spinner when fetcher is loading and has no data', () => {
159+
fullProductFetcherState = {
160+
load: mockLoad,
161+
data: null,
162+
state: 'loading' as const,
163+
success: false,
164+
};
165+
renderEditContainer();
166+
167+
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
168+
expect(screen.getByText(t('editItem:loadingProduct'))).toBeInTheDocument();
169+
});
170+
171+
test('does not show loading when fetcher has data', () => {
172+
fullProductFetcherState = {
173+
load: mockLoad,
174+
data: variantProduct,
175+
state: 'idle' as const,
176+
success: true,
177+
};
178+
renderEditContainer();
179+
180+
expect(document.querySelector('.animate-spin')).not.toBeInTheDocument();
181+
});
182+
});
183+
184+
describe('product display', () => {
185+
test('falls back to product prop when fetcher has no data yet', () => {
186+
fullProductFetcherState = {
187+
load: mockLoad,
188+
data: null,
189+
state: 'idle' as const,
190+
success: false,
191+
};
192+
renderEditContainer();
193+
194+
expect(screen.getByText(basketProduct.name as string)).toBeInTheDocument();
195+
});
196+
197+
test('renders full product data once fetcher resolves', () => {
198+
fullProductFetcherState = {
199+
load: mockLoad,
200+
data: variantProduct,
201+
state: 'idle' as const,
202+
success: true,
203+
};
204+
renderEditContainer();
205+
206+
expect(screen.getByText(variantProduct.name as string)).toBeInTheDocument();
207+
});
208+
});
209+
210+
describe('variant fetcher', () => {
211+
test('does not trigger variant fetch when selected variant matches productId', () => {
212+
fullProductFetcherState = {
213+
load: mockLoad,
214+
data: variantProduct,
215+
state: 'idle' as const,
216+
success: true,
217+
};
218+
renderEditContainer();
219+
220+
const variantFetcherCall = mockUseScapiFetcher.mock.calls.find((call) => {
221+
const params = call[2].params as {
222+
query: { expand?: string[] };
223+
};
224+
const expand = params?.query?.expand;
225+
return expand && !expand.includes('variations');
226+
});
227+
228+
expect(variantFetcherCall).toBeDefined();
229+
const params = (variantFetcherCall as NonNullable<typeof variantFetcherCall>)[2].params as {
230+
path: { id: string };
231+
};
232+
expect(params.path.id).toBe('');
233+
});
234+
235+
test('configures variant fetcher with expand params for availability, images, prices, promotions', () => {
236+
fullProductFetcherState = {
237+
load: mockLoad,
238+
data: variantProduct,
239+
state: 'idle' as const,
240+
success: true,
241+
};
242+
renderEditContainer();
243+
244+
const variantFetcherCall = mockUseScapiFetcher.mock.calls.find((call) => {
245+
const params = call[2].params as {
246+
query: { expand?: string[] };
247+
};
248+
const expand = params?.query?.expand;
249+
return expand && !expand.includes('variations');
250+
});
251+
252+
expect(variantFetcherCall).toBeDefined();
253+
const params = (variantFetcherCall as NonNullable<typeof variantFetcherCall>)[2].params as {
254+
query: { expand: string[] };
255+
};
256+
expect(params.query.expand).toEqual(['availability', 'images', 'prices', 'promotions']);
257+
});
258+
});
259+
260+
describe('modal rendering', () => {
261+
test('renders dialog with edit title when open', () => {
262+
fullProductFetcherState = {
263+
load: mockLoad,
264+
data: variantProduct,
265+
state: 'idle' as const,
266+
success: true,
267+
};
268+
renderEditContainer();
269+
270+
expect(screen.getByRole('dialog')).toBeInTheDocument();
271+
expect(screen.getByText(t('editItem:title'))).toBeInTheDocument();
272+
});
273+
274+
test('does not render dialog when closed', () => {
275+
renderEditContainer({ open: false });
276+
277+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
278+
});
279+
});
280+
});

0 commit comments

Comments
 (0)