diff --git a/js/src/data/actions.js b/js/src/data/actions.js index 5b371a1a22..7a2881f4aa 100644 --- a/js/src/data/actions.js +++ b/js/src/data/actions.js @@ -15,6 +15,7 @@ import { } from './constants'; import { handleApiError } from '.~/utils/handleError'; import { adaptAdsCampaign } from './adapters'; +import { isWCIos, isWCAndroid } from '.~/utils/isMobileApp'; /** * @typedef {import('.~/data/types.js').AssetEntityGroupUpdateBody} AssetEntityGroupUpdateBody @@ -798,6 +799,14 @@ export function* saveTargetAudience( targetAudience ) { * @throws { { message: string } } Will throw an error if the campaign creation fails. */ export function* createAdsCampaign( amount, countryCodes ) { + let label = 'wc-web'; + + if ( isWCIos() ) { + label = 'wc-ios'; + } else if ( isWCAndroid() ) { + label = 'wc-android'; + } + try { const createdCampaign = yield apiFetch( { path: `${ API_NAMESPACE }/ads/campaigns`, @@ -805,6 +814,7 @@ export function* createAdsCampaign( amount, countryCodes ) { data: { amount, targeted_locations: countryCodes, + label, }, } ); diff --git a/js/src/data/test/createAdsCampaign.test.js b/js/src/data/test/createAdsCampaign.test.js new file mode 100644 index 0000000000..771ae1f56b --- /dev/null +++ b/js/src/data/test/createAdsCampaign.test.js @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react-hooks'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { useAppDispatch } from '.~/data'; + +jest.mock( '@wordpress/api-fetch', () => { + const impl = jest.fn().mockName( '@wordpress/api-fetch' ); + impl.use = jest.fn().mockName( 'apiFetch.use' ); + return impl; +} ); + +describe( 'createAdsCampaign', () => { + let navigatorGetter; + + const mockFetch = jest + .fn() + .mockResolvedValue( { targeted_locations: [ 'ES' ] } ); + + beforeEach( () => { + jest.clearAllMocks(); + apiFetch.mockImplementation( ( args ) => { + return mockFetch( args ); + } ); + + navigatorGetter = jest.spyOn( window.navigator, 'userAgent', 'get' ); + } ); + + it( 'If the user agent is not set to wc-ios or wc-android, the label should default to wc-web', async () => { + const { result } = renderHook( () => useAppDispatch() ); + + await result.current.createAdsCampaign( 100, [ 'ES' ] ); + + expect( mockFetch ).toHaveBeenCalledTimes( 1 ); + expect( mockFetch ).toHaveBeenCalledWith( { + path: '/wc/gla/ads/campaigns', + method: 'POST', + data: { + amount: 100, + targeted_locations: [ 'ES' ], + label: 'wc-web', + }, + } ); + } ); + + it( 'If the user agent is set to wc-ios the label should be wc-ios', async () => { + navigatorGetter.mockReturnValue( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wc-ios/19.7.1' + ); + + const { result } = renderHook( () => useAppDispatch() ); + + await result.current.createAdsCampaign( 100, [ 'ES' ] ); + + expect( mockFetch ).toHaveBeenCalledTimes( 1 ); + expect( mockFetch ).toHaveBeenCalledWith( { + path: '/wc/gla/ads/campaigns', + method: 'POST', + data: { + amount: 100, + targeted_locations: [ 'ES' ], + label: 'wc-ios', + }, + } ); + } ); + + it( 'If the user agent is set to wc-android the label should be wc-android', async () => { + navigatorGetter.mockReturnValue( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wc-android/19.7.1' + ); + + const { result } = renderHook( () => useAppDispatch() ); + + await result.current.createAdsCampaign( 100, [ 'ES' ] ); + + expect( mockFetch ).toHaveBeenCalledTimes( 1 ); + expect( mockFetch ).toHaveBeenCalledWith( { + path: '/wc/gla/ads/campaigns', + method: 'POST', + data: { + amount: 100, + targeted_locations: [ 'ES' ], + label: 'wc-android', + }, + } ); + } ); +} ); diff --git a/js/src/utils/isMobile.test.js b/js/src/utils/isMobile.test.js new file mode 100644 index 0000000000..18cf238ec9 --- /dev/null +++ b/js/src/utils/isMobile.test.js @@ -0,0 +1,38 @@ +/** + * Internal dependencies + */ +import { isWCIos, isWCAndroid } from '.~/utils/isMobileApp'; + +describe( 'isMobile', () => { + let navigatorGetter; + + beforeEach( () => { + // To initialize `pagePaths`. + navigatorGetter = jest.spyOn( window.navigator, 'userAgent', 'get' ); + } ); + + it( 'isWCIos', () => { + navigatorGetter.mockReturnValue( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wc-ios/19.7.1' + ); + + expect( isWCIos() ).toBe( true ); + } ); + + it( 'isWCAndroid', () => { + navigatorGetter.mockReturnValue( + 'Mozilla/5.0 (iPhone; CPU Samsung ) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wc-android/19.7.1' + ); + + expect( isWCAndroid() ).toBe( true ); + } ); + + it( 'is not WCAndroid or isWCIos', () => { + navigatorGetter.mockReturnValue( + 'Mozilla/5.0 (iPhone; CPU ) AppleWebKit/605.1.15 (KHTML, like Gecko)' + ); + + expect( isWCAndroid() ).toBe( false ); + expect( isWCIos() ).toBe( false ); + } ); +} ); diff --git a/js/src/utils/isMobileApp.js b/js/src/utils/isMobileApp.js new file mode 100644 index 0000000000..78584cbe67 --- /dev/null +++ b/js/src/utils/isMobileApp.js @@ -0,0 +1,20 @@ +/** + * Check if the WC app is running on iOS. + * + * @return {boolean} Whether the WC app is running on iOS. + * + */ +const isWCIos = () => { + return window.navigator.userAgent.toLowerCase().includes( 'wc-ios' ); +}; + +/** + * Check if the WC app is running on Android. + * + * @return {boolean} Whether the WC app is running on Android. + */ +const isWCAndroid = () => { + return window.navigator.userAgent.toLowerCase().includes( 'wc-android' ); +}; + +export { isWCIos, isWCAndroid }; diff --git a/src/API/Site/Controllers/Ads/CampaignController.php b/src/API/Site/Controllers/Ads/CampaignController.php index 90d220117e..6fefab6936 100644 --- a/src/API/Site/Controllers/Ads/CampaignController.php +++ b/src/API/Site/Controllers/Ads/CampaignController.php @@ -164,6 +164,7 @@ protected function create_campaign_callback(): callable { * @property float amount Campaign budget. * @property string country Base target country code. * @property string targeted_locations Additional target country codes. + * @property string source The source of the campaign creation. */ do_action( 'woocommerce_gla_track_event', @@ -175,6 +176,7 @@ protected function create_campaign_callback(): callable { 'amount' => $campaign['amount'], 'country' => $campaign['country'], 'targeted_locations' => join( ',', $campaign['targeted_locations'] ), + 'source' => $fields['label'] ?? '', ] ); diff --git a/tests/Framework/RESTControllerUnitTest.php b/tests/Framework/RESTControllerUnitTest.php index e2f83b2907..775a43f301 100644 --- a/tests/Framework/RESTControllerUnitTest.php +++ b/tests/Framework/RESTControllerUnitTest.php @@ -76,10 +76,11 @@ public function tearDown(): void { * @param string $endpoint Endpoint to hit. * @param string $type Type of request e.g GET or POST. * @param array $params Request body or query. + * @param array $headers Request headers in format key => value. * * @return Response */ - protected function do_request( string $endpoint, string $type = 'GET', array $params = [] ): object { + protected function do_request( string $endpoint, string $type = 'GET', array $params = [], array $headers = [] ): object { $request = new Request( $type, $endpoint ); if ( 'GET' === $type ) { @@ -90,6 +91,10 @@ protected function do_request( string $endpoint, string $type = 'GET', array $pa $request->set_body( wp_json_encode( $params ) ); } + foreach ( $headers as $key => $value ) { + $request->set_header( $key, $value ); + } + return $this->server->dispatch_request( $request ); } } diff --git a/tests/Unit/API/Site/Controllers/Ads/CampaignControllerTest.php b/tests/Unit/API/Site/Controllers/Ads/CampaignControllerTest.php index de1d5c01ec..5491201228 100644 --- a/tests/Unit/API/Site/Controllers/Ads/CampaignControllerTest.php +++ b/tests/Unit/API/Site/Controllers/Ads/CampaignControllerTest.php @@ -300,6 +300,7 @@ public function test_create_campaign() { 'name' => 'New Campaign', 'amount' => 20, 'targeted_locations' => [ 'US', 'GB', 'TW' ], + 'label' => 'wc-web', ]; $expected = [ @@ -307,7 +308,48 @@ public function test_create_campaign() { 'status' => 'enabled', 'type' => 'performance_max', 'country' => self::BASE_COUNTRY, - ] + $campaign_data; + ] + array_diff_key( $campaign_data, [ 'label' => 'wc-web' ] ); + + $this->ads_campaign->expects( $this->once() ) + ->method( 'create_campaign' ) + ->with( $campaign_data ) + ->willReturn( $expected ); + + $this->expect_track_event( + 'created_campaign', + [ + 'id' => self::TEST_CAMPAIGN_ID, + 'status' => 'enabled', + 'name' => 'New Campaign', + 'amount' => 20, + 'country' => self::BASE_COUNTRY, + 'targeted_locations' => 'US,GB,TW', + 'source' => 'wc-web', + ] + ); + + $response = $this->do_request( self::ROUTE_CAMPAIGNS, 'POST', $campaign_data ); + + $this->assertEquals( $expected, $response->get_data() ); + $this->assertEquals( 200, $response->get_status() ); + } + + public function test_create_campaign_with_label() { + $campaign_data = [ + 'name' => 'New Campaign', + 'amount' => 20, + 'targeted_locations' => [ 'US', 'GB', 'TW' ], + ]; + + $expected = [ + 'id' => self::TEST_CAMPAIGN_ID, + 'status' => 'enabled', + 'type' => 'performance_max', + 'country' => self::BASE_COUNTRY, + 'name' => 'New Campaign', + 'amount' => 20, + 'targeted_locations' => [ 'US', 'GB', 'TW' ], + ]; $this->ads_campaign->expects( $this->once() ) ->method( 'create_campaign' ) @@ -323,6 +365,7 @@ public function test_create_campaign() { 'amount' => 20, 'country' => self::BASE_COUNTRY, 'targeted_locations' => 'US,GB,TW', + 'source' => '', ] );