diff --git a/tests/e2e/bin/test-data.php b/tests/e2e/bin/test-data.php index 02fc9edaad..55de84dcaa 100644 --- a/tests/e2e/bin/test-data.php +++ b/tests/e2e/bin/test-data.php @@ -33,8 +33,56 @@ function register_routes() { ], ], ); + register_rest_route( + 'wc/v3', + 'gla-test/onboarded-merchant', + [ + [ + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\set_onboarded_merchant', + 'permission_callback' => __NAMESPACE__ . '\permissions', + ], + [ + 'methods' => 'DELETE', + 'callback' => __NAMESPACE__ . '\clear_onboarded_merchant', + 'permission_callback' => __NAMESPACE__ . '\permissions', + ], + ], + ); +} + +/** + * Set the onboarded merchant options. + */ +function set_onboarded_merchant() { + /** @var OptionsInterface $options */ + $options = woogle_get_container()->get( OptionsInterface::class ); + $options->update( + OptionsInterface::REDIRECT_TO_ONBOARDING, + 'no' + ); + $options->update( + OptionsInterface::MC_SETUP_COMPLETED_AT, + 1693215209 + ); + $options->update( + OptionsInterface::GOOGLE_CONNECTED, + true + ); +} + +/** + * Clear a previously set onboarded merchant. + */ +function clear_onboarded_merchant() { + /** @var OptionsInterface $options */ + $options = woogle_get_container()->get( OptionsInterface::class ); + $options->delete( OptionsInterface::REDIRECT_TO_ONBOARDING ); + $options->delete( OptionsInterface::MC_SETUP_COMPLETED_AT ); + $options->delete( OptionsInterface::GOOGLE_CONNECTED ); } + /** * Set the Ads Conversion Action to test values. */ diff --git a/tests/e2e/config/playwright.config.js b/tests/e2e/config/playwright.config.js index ae3720a15d..b3d99e4407 100644 --- a/tests/e2e/config/playwright.config.js +++ b/tests/e2e/config/playwright.config.js @@ -15,6 +15,9 @@ module.exports = defineConfig( { timeout: 20 * 1000, }, + /* Number of workers, See discusion: https://github.com/woocommerce/google-listings-and-ads/pull/2080#issuecomment-1698810270 */ + workers: 1, + /* Retry on CI only */ retries: process.env.CI ? 2 : 0, diff --git a/tests/e2e/specs/dashboard/edit-free-listings.test.js b/tests/e2e/specs/dashboard/edit-free-listings.test.js new file mode 100644 index 0000000000..87a8e60858 --- /dev/null +++ b/tests/e2e/specs/dashboard/edit-free-listings.test.js @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +import { expect, test } from '@playwright/test'; +/** + * Internal dependencies + */ +import { clearOnboardedMerchant, setOnboardedMerchant } from '../../utils/api'; +import { checkSnackBarMessage } from '../../utils/page'; +import DashboardPage from '../../utils/pages/dashboard.js'; +import EditFreeListingsPage from '../../utils/pages/edit-free-listings.js'; + +test.use( { storageState: process.env.ADMINSTATE } ); + +test.describe.configure( { mode: 'serial' } ); + +/** + * @type {import('../../utils/pages/dashboard.js').default} dashboardPage + */ +let dashboardPage = null; + +/** + * @type {import('../../utils/pages/edit-free-listings.js').default} editFreeListingsPage + */ +let editFreeListingsPage = null; + +/** + * @type {import('@playwright/test').Page} page + */ +let page = null; + +test.describe( 'Edit Free Listings', () => { + test.beforeAll( async ( { browser } ) => { + page = await browser.newPage(); + dashboardPage = new DashboardPage( page ); + editFreeListingsPage = new EditFreeListingsPage( page ); + await setOnboardedMerchant(); + await dashboardPage.mockRequests(); + await dashboardPage.goto(); + } ); + + test.afterAll( async () => { + await clearOnboardedMerchant(); + await page.close(); + } ); + + test( 'Dashboard page contains Free Listings', async () => { + await expect( dashboardPage.freeListingRow ).toContainText( + 'Free listings' + ); + + await expect( dashboardPage.editFreeListingButton ).toBeEnabled(); + } ); + + test( 'Edit Free Listings should show modal', async () => { + await dashboardPage.clickEditFreeListings(); + + await page.waitForLoadState( 'domcontentloaded' ); + + const continueToEditButton = + await dashboardPage.getContinueToEditButton(); + const dontEditButton = await dashboardPage.getDontEditButton(); + await expect( continueToEditButton ).toBeEnabled(); + await expect( dontEditButton ).toBeEnabled(); + } ); + + test( 'Continue to edit Free listings', async () => { + await dashboardPage.clickContinueToEditButton(); + await page.waitForLoadState( 'domcontentloaded' ); + } ); + + test( 'Check recommended shipping settings', async () => { + await editFreeListingsPage.checkRecommendShippingSettings(); + await editFreeListingsPage.fillCountriesShippingTimeInput( '5' ); + await editFreeListingsPage.checkDestinationBasedTaxRates(); + const saveChangesButton = + await editFreeListingsPage.getSaveChangesButton(); + await expect( saveChangesButton ).toBeEnabled(); + } ); + + test( 'Save changes', async () => { + const awaitForRequests = editFreeListingsPage.registerSavingRequests(); + await editFreeListingsPage.mockSuccessfulSavingSettingsResponse(); + await editFreeListingsPage.clickSaveChanges(); + const requests = await awaitForRequests; + const settingsResponse = await ( + await requests[ 0 ].response() + ).json(); + + expect( settingsResponse.status ).toBe( 'success' ); + expect( settingsResponse.message ).toBe( + 'Merchant Center Settings successfully updated.' + ); + expect( settingsResponse.data.shipping_time ).toBe( 'flat' ); + expect( settingsResponse.data.tax_rate ).toBe( 'destination' ); + + await checkSnackBarMessage( + page, + 'Your changes to your Free Listings have been saved and will be synced to your Google Merchant Center account.' + ); + } ); +} ); diff --git a/tests/e2e/utils/api.js b/tests/e2e/utils/api.js index f009e491cb..4c1c2bca7c 100644 --- a/tests/e2e/utils/api.js +++ b/tests/e2e/utils/api.js @@ -61,3 +61,17 @@ export async function setConversionID() { export async function clearConversionID() { await api().delete( 'gla-test/conversion-id' ); } + +/** + * Set Onboarded Merchant. + */ +export async function setOnboardedMerchant() { + await api().post( 'gla-test/onboarded-merchant' ); +} + +/** + * Clear Onboarded Merchant. + */ +export async function clearOnboardedMerchant() { + await api().delete( 'gla-test/onboarded-merchant' ); +} diff --git a/tests/e2e/utils/mock-requests.js b/tests/e2e/utils/mock-requests.js new file mode 100644 index 0000000000..406ccf1caa --- /dev/null +++ b/tests/e2e/utils/mock-requests.js @@ -0,0 +1,96 @@ +/** + * Mock Requests + * + * This class is used to mock requests to the server. + */ +export default class MockRequests { + /** + * @param {import('@playwright/test').Page} page + */ + constructor( page ) { + this.page = page; + } + + /** + * Fulfill a request with a payload. + * + * @param {RegExp|string} url The url to fulfill. + * @param {Object} payload The payload to send. + * @return {Promise} + */ + async fulfillRequest( url, payload ) { + await this.page.route( url, ( route ) => + route.fulfill( { + content: 'application/json', + headers: { 'Access-Control-Allow-Origin': '*' }, + body: JSON.stringify( payload ), + } ) + ); + } + + /** + * Fulfill the MC Report Program request. + * + * @param {Object} payload + * @return {Promise} + */ + async fulfillMCReportProgram( payload ) { + await this.fulfillRequest( + /\/wc\/gla\/mc\/reports\/programs\b/, + payload + ); + } + + /** + * Fulfill the Target Audience request. + * + * @param {Object} payload + * @return {Promise} + */ + async fulfillTargetAudience( payload ) { + await this.fulfillRequest( + /\/wc\/gla\/mc\/target_audience\b/, + payload + ); + } + + /** + * Fulfill the JetPack Connection request. + * + * @param {Object} payload + * @return {Promise} + */ + async fulfillJetPackConnection( payload ) { + await this.fulfillRequest( /\/wc\/gla\/jetpack\/connected\b/, payload ); + } + + /** + * Fulfill the Google Connection request. + * + * @param {Object} payload + * @return {Promise} + */ + async fulfillGoogleConnection( payload ) { + await this.fulfillRequest( /\/wc\/gla\/google\/connected\b/, payload ); + } + + /** + * Fulfill the Ads Connection request. + * + * @param {Object} payload + * @return {Promise} + */ + async fulfillAdsConnection( payload ) { + await this.fulfillRequest( /\/wc\/gla\/ads\/connection\b/, payload ); + } + + /** + * Fulfill the Sync Settings Connection request. + * + * @param {Object} payload + * @return {Promise} + */ + async fulfillSettingsSync( payload ) { + await this.fulfillRequest( /\/wc\/gla\/mc\/settings\/sync\b/, payload ); + } +} diff --git a/tests/e2e/utils/page.js b/tests/e2e/utils/page.js new file mode 100644 index 0000000000..9ae0013ad4 --- /dev/null +++ b/tests/e2e/utils/page.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +const { expect } = require( '@playwright/test' ); + +/** + * Check the snackbar message. + * + * @param {import('@playwright/test').Page} page The current page. + * @param {string} text The text to check. + * + * @return {Promise} + */ +export async function checkSnackBarMessage( page, text ) { + const snackbarMessage = page.locator( '.components-snackbar__content' ); + await snackbarMessage.waitFor(); + expect( snackbarMessage ).toHaveText( text ); +} diff --git a/tests/e2e/utils/pages/dashboard.js b/tests/e2e/utils/pages/dashboard.js new file mode 100644 index 0000000000..925426330a --- /dev/null +++ b/tests/e2e/utils/pages/dashboard.js @@ -0,0 +1,133 @@ +/** + * Internal dependencies + */ +import MockRequests from '../mock-requests'; + +/** + * Dashboard page object class. + */ +export default class DashboardPage extends MockRequests { + /** + * @param {import('@playwright/test').Page} page + */ + constructor( page ) { + super( page ); + this.page = page; + this.freeListingRow = this.page.locator( + '.gla-all-programs-table-card table tr:nth-child(2)' + ); + this.editFreeListingButton = this.freeListingRow.getByRole( 'button', { + name: 'Edit', + } ); + } + + /** + * Close the current page. + * + * @return {Promise} + */ + async closePage() { + await this.page.close(); + } + + /** + * Mock all requests related to external accounts such as Merchant Center, Google, etc. + * + * @return {Promise} + */ + async mockRequests() { + // Mock Reports Programs + await this.fulfillMCReportProgram( { + free_listings: null, + products: null, + intervals: null, + totals: { + clicks: 0, + impressions: 0, + }, + next_page: null, + } ); + + await this.fulfillTargetAudience( { + location: 'selected', + countries: [ 'US' ], + locale: 'en_US', + language: 'English', + } ); + + await this.fulfillJetPackConnection( { + active: 'yes', + owner: 'yes', + displayName: 'John', + email: 'john@email.com', + } ); + + await this.fulfillGoogleConnection( { + active: 'yes', + email: 'john@email.com', + scope: [], + } ); + + await this.fulfillAdsConnection( { + id: 0, + currency: null, + symbol: '$', + status: 'disconnected', + } ); + } + + /** + * Go to the dashboard page. + * + * @return {Promise} + */ + async goto() { + await this.page.goto( + '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard', + { waitUntil: 'domcontentloaded' } + ); + } + + /** + * Click the edit free listings button. + * + * @return {Promise} + */ + async clickEditFreeListings() { + await this.editFreeListingButton.click(); + } + + /** + * Get the continue to edit button from the modal. + * + * @return {Promise} Get the continue to edit button from the modal. + */ + async getContinueToEditButton() { + return this.page.getByRole( 'button', { + name: 'Continue to edit', + exact: true, + } ); + } + + /** + * Get the don't edit button from the modal. + * + * @return {Promise} Get the don't edit button from the modal. + */ + async getDontEditButton() { + return this.page.getByRole( 'button', { + name: "Don't edit", + exact: true, + } ); + } + + /** + * Click the continue to edit button from the modal. + * + * @return {Promise} + */ + async clickContinueToEditButton() { + const continueToEditButton = await this.getContinueToEditButton(); + await continueToEditButton.click(); + } +} diff --git a/tests/e2e/utils/pages/edit-free-listings.js b/tests/e2e/utils/pages/edit-free-listings.js new file mode 100644 index 0000000000..aea8797814 --- /dev/null +++ b/tests/e2e/utils/pages/edit-free-listings.js @@ -0,0 +1,114 @@ +/** + * Internal dependencies + */ +import MockRequests from '../mock-requests'; + +export default class EditFreeListingsPage extends MockRequests { + /** + * @param {import('@playwright/test').Page} page + */ + constructor( page ) { + super( page ); + this.page = page; + } + + /** + * Get Save Changes button. + * + * @return {Promise} Get Save Changes button. + */ + async getSaveChangesButton() { + return this.page.getByRole( 'button', { + name: 'Save changes', + exact: true, + } ); + } + + /** + * Click the Save Changes button. + * + * @return {Promise} + */ + async clickSaveChanges() { + const saveChangesButton = await this.getSaveChangesButton(); + await saveChangesButton.click(); + } + + /** + * Check the recommended shipping settings. + * + * @return {Promise} + */ + async checkRecommendShippingSettings() { + return this.page + .locator( + 'text=Recommended: Automatically sync my store’s shipping settings to Google.' + ) + .check(); + } + /** + * Fill the countries shipping time input. + * + * @param {string} input The shipping time + * @return {Promise} + */ + async fillCountriesShippingTimeInput( input ) { + await this.page.locator( '.countries-time input' ).fill( input ); + } + + /** + * Check the destination based tax rates. + * + * @return {Promise} + */ + async checkDestinationBasedTaxRates() { + await this.page + .locator( 'text=My store uses destination-based tax rates.' ) + .check(); + } + + /** + * Mock the successful saving settings response. + * + * @return {Promise} + */ + async mockSuccessfulSavingSettingsResponse() { + await this.fulfillSettingsSync( { + status: 'success', + message: 'Successfully synchronized settings with Google.', + } ); + } + + /** + * Register the requests when the save button is clicked. + * + * @return {Promise} The requests. + */ + registerSavingRequests() { + const targetAudienceRequest = this.page.waitForRequest( + ( request ) => + request.url().includes( '/gla/mc/target_audience' ) && + request.method() === 'POST' && + request.postDataJSON().countries[ 0 ] === 'US' + ); + const settingsRequest = this.page.waitForRequest( + ( request ) => + request.url().includes( '/gla/mc/settings' ) && + request.method() === 'POST' && + request.postDataJSON().shipping_rate === 'automatic' && + request.postDataJSON().shipping_time === 'flat' + ); + + const syncRequest = this.page.waitForRequest( + ( request ) => + request.url().includes( '/gla/mc/settings/sync' ) && + request.method() === 'POST' + ); + + return Promise.all( [ + settingsRequest, + targetAudienceRequest, + syncRequest, + ] ); + } +}