Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/src/app/address/AddressForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ const AddressForm: React.FC<AddressFormProps> = ({
'CHECKOUT-9019.use_new_phone_number_validation',
false,
);
const isNewGooglePlacesApiEnabled = isExperimentEnabled(
config?.checkoutSettings,
'CHECKOUT-10026.new_google_places_api',
false,
);
const countriesWithAutocomplete = ['US', 'CA', 'AU', 'NZ', 'GB'];

const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -174,6 +179,7 @@ const AddressForm: React.FC<AddressFormProps> = ({
countryCode={countryCode}
field={field}
isFloatingLabelEnabled={isFloatingLabelEnabledValue}
isNewPlacesApiEnabled={isNewGooglePlacesApiEnabled}
key={field.id}
nextElement={nextElementRef.current || undefined}
onChange={handleAutocompleteChange}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import userEvent from '@testing-library/user-event';
import React from 'react';

import { render, screen, waitFor } from '@bigcommerce/checkout/test-utils';

import GoogleAutocomplete, {
type GoogleAutocompleteProps,
resetNewGooglePlacesApiState,
} from './GoogleAutocomplete';
import LegacyGoogleAutocompleteService from './GoogleAutocompleteService';
import { NewGooglePlacesApiService } from './newGooglePlacesApi/NewGooglePlacesApiService';

jest.mock('./GoogleAutocompleteService');
jest.mock('./newGooglePlacesApi/NewGooglePlacesApiService');

const MockLegacyService = LegacyGoogleAutocompleteService as jest.MockedClass<
typeof LegacyGoogleAutocompleteService
>;
const MockPlacesApiService = NewGooglePlacesApiService as jest.MockedClass<
typeof NewGooglePlacesApiService
>;

const legacySuggestions = [
{
description: '123 Legacy St, New York',
structured_formatting: { main_text: '123 Legacy St' },
matched_substrings: [],
place_id: 'legacy-place-1',
},
];

const legacyPlaceResult = {
name: '123 Legacy St',
address_components: [],
} as google.maps.places.PlaceResult;

const newApiSuggestions = [
{ id: 'new-place-1', label: '123 New API Ave', value: '123 New API Ave' },
];

const newApiPlaceResult = {
name: '123 New API Ave',
address_components: [],
} as google.maps.places.PlaceResult;

const gRpcPermissionDeniedErrorMock = { name: 'RpcError', code: 7 };
const gRpcSomeOtherErrorMock = { name: 'RpcError', code: 14 };

describe('GoogleAutocomplete', () => {
let mockGetLegacyApiSuggestions: jest.Mock;
let mockGetLegacyApiPlaceDetails: jest.Mock;
let mockGetNewApiSuggestions: jest.Mock;
let mockGetNewApiPlaceDetails: jest.Mock;
let defaultProps: GoogleAutocompleteProps;

beforeEach(() => {
resetNewGooglePlacesApiState();

mockGetLegacyApiSuggestions = jest.fn();
mockGetLegacyApiPlaceDetails = jest.fn();
mockGetNewApiSuggestions = jest.fn().mockResolvedValue(newApiSuggestions);
mockGetNewApiPlaceDetails = jest.fn().mockResolvedValue(newApiPlaceResult);

MockLegacyService.mockImplementation(
() =>
({
getAutocompleteService: jest.fn().mockResolvedValue({
getPlacePredictions: mockGetLegacyApiSuggestions,
}),
getPlacesServices: jest
.fn()
.mockResolvedValue({ getDetails: mockGetLegacyApiPlaceDetails }),
}) as unknown as LegacyGoogleAutocompleteService,
);

MockPlacesApiService.mockImplementation(
() =>
({
getSuggestions: mockGetNewApiSuggestions,
getPlaceDetails: mockGetNewApiPlaceDetails,
}) as unknown as NewGooglePlacesApiService,
);

defaultProps = {
apiKey: 'test-api-key',
isAutocompleteEnabled: true,
isNewPlacesApiEnabled: true,
onSelect: jest.fn(),
onChange: jest.fn(),
};
});

describe('new API available (preferred path)', () => {
it('shows suggestions from the new API', async () => {
render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '123');

await screen.findByText('123 New API Ave');
expect(mockGetLegacyApiSuggestions).not.toHaveBeenCalled();
});

it('calls onSelect with the new API place result when a suggestion is picked', async () => {
render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '123');
await screen.findByText('123 New API Ave');
await userEvent.click(screen.getByText('123 New API Ave'));

await waitFor(() =>
expect(defaultProps.onSelect).toHaveBeenCalledWith(
newApiPlaceResult,
expect.objectContaining({ id: 'new-place-1' }),
),
);
expect(mockGetLegacyApiPlaceDetails).not.toHaveBeenCalled();
});
});

describe('new API unavailable (fall back to legacy)', () => {
beforeEach(() => {
mockGetLegacyApiSuggestions.mockImplementation((_req, cb) =>
cb(legacySuggestions, 'OK'),
);
mockGetLegacyApiPlaceDetails.mockImplementation((_req, cb) =>
cb(legacyPlaceResult, 'OK'),
);
});

it('falls back to the legacy service for suggestions when the new API is denied', async () => {
mockGetNewApiSuggestions.mockRejectedValue(gRpcPermissionDeniedErrorMock);

render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '123');

await screen.findByText('123 Legacy St, New York');
expect(mockGetLegacyApiSuggestions).toHaveBeenCalled();
});

it('does not fall back to legacy for a non-permission suggestions failure', async () => {
mockGetNewApiSuggestions.mockRejectedValue(gRpcSomeOtherErrorMock);

render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '123');

await waitFor(() => expect(mockGetNewApiSuggestions).toHaveBeenCalled());
expect(screen.queryByText('123 Legacy St, New York')).not.toBeInTheDocument();
expect(mockGetLegacyApiSuggestions).not.toHaveBeenCalled();
});

it('latches onto legacy after a permission denial and stops retrying the new API', async () => {
mockGetNewApiSuggestions.mockRejectedValue(gRpcPermissionDeniedErrorMock);

render(<GoogleAutocomplete {...defaultProps} />);

const input = screen.getByRole('textbox');

// First request hits the new API, is denied, and latches the fallback flag.
await userEvent.type(input, '1');
await screen.findByText('123 Legacy St, New York');
expect(mockGetNewApiSuggestions).toHaveBeenCalledTimes(1);

// A later request goes straight to legacy without retrying the new API.
await userEvent.type(input, '2');
// legacy is called twice for both cases
await waitFor(() => expect(mockGetLegacyApiSuggestions).toHaveBeenCalledTimes(2));
// new api is called only once for previous input
expect(mockGetNewApiSuggestions).toHaveBeenCalledTimes(1);
});

it('keeps retrying the new API on later input after a transient failure, without falling back', async () => {
mockGetNewApiSuggestions.mockRejectedValue(gRpcSomeOtherErrorMock);

render(<GoogleAutocomplete {...defaultProps} />);

const input = screen.getByRole('textbox');

// First request fails transiently; no fallback and no latch, since it's not a permission denial.
await userEvent.type(input, '1');
await waitFor(() => expect(mockGetNewApiSuggestions).toHaveBeenCalledTimes(1));
expect(mockGetLegacyApiSuggestions).not.toHaveBeenCalled();

// The new API is tried again on the next input, since the failure was not permanent.
await userEvent.type(input, '2');
await waitFor(() => expect(mockGetNewApiSuggestions).toHaveBeenCalledTimes(2));
});

it('falls back to the legacy service for place details when the new API denies permission', async () => {
// Suggestions still come from the new API so the dropdown has an item to click,
// but getPlaceDetails is denied, forcing the legacy getDetails fallback.
mockGetNewApiPlaceDetails.mockRejectedValue(gRpcPermissionDeniedErrorMock);

render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '1');
await screen.findByText('123 New API Ave');
await userEvent.click(screen.getByText('123 New API Ave'));

await waitFor(() =>
expect(defaultProps.onSelect).toHaveBeenCalledWith(
legacyPlaceResult,
expect.objectContaining({ id: 'new-place-1' }),
),
);
expect(mockGetLegacyApiPlaceDetails).toHaveBeenCalled();
});

it('does not fall back to legacy for a non-permission place details failure', async () => {
mockGetNewApiPlaceDetails.mockRejectedValue(new Error('new API down'));

render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '1');
await screen.findByText('123 New API Ave');
await userEvent.click(screen.getByText('123 New API Ave'));

await waitFor(() => expect(mockGetNewApiPlaceDetails).toHaveBeenCalled());
expect(mockGetLegacyApiPlaceDetails).not.toHaveBeenCalled();
expect(defaultProps.onSelect).not.toHaveBeenCalled();
});
});

describe('new API disabled via experiment flag', () => {
beforeEach(() => {
mockGetLegacyApiSuggestions.mockImplementation((_req, cb) =>
cb(legacySuggestions, 'OK'),
);
mockGetLegacyApiPlaceDetails.mockImplementation((_req, cb) =>
cb(legacyPlaceResult, 'OK'),
);
});

it('goes straight to the legacy service for suggestions without calling the new API', async () => {
render(<GoogleAutocomplete {...defaultProps} isNewPlacesApiEnabled={false} />);

await userEvent.type(screen.getByRole('textbox'), '123');

await screen.findByText('123 Legacy St, New York');
expect(mockGetNewApiSuggestions).not.toHaveBeenCalled();
});

it('goes straight to the legacy service for place details without calling the new API', async () => {
render(<GoogleAutocomplete {...defaultProps} isNewPlacesApiEnabled={false} />);

await userEvent.type(screen.getByRole('textbox'), '1');
await screen.findByText('123 Legacy St, New York');
await userEvent.click(screen.getByText('123 Legacy St, New York'));

await waitFor(() =>
expect(defaultProps.onSelect).toHaveBeenCalledWith(
legacyPlaceResult,
expect.objectContaining({ id: 'legacy-place-1' }),
),
);
expect(mockGetNewApiPlaceDetails).not.toHaveBeenCalled();
});
});
});
Loading