diff --git a/includes/class-wc-countries.php b/includes/class-wc-countries.php index ae15bb442ab..064f035cabc 100644 --- a/includes/class-wc-countries.php +++ b/includes/class-wc-countries.php @@ -602,7 +602,12 @@ class WC_Countries { array( '{first_name}' => $args['first_name'], '{last_name}' => $args['last_name'], - '{name}' => $args['first_name'] . ' ' . $args['last_name'], + '{name}' => sprintf( + /* translators: 1: first name 2: last name */ + _x( '%1$s %2$s', 'full name', 'woocommerce' ), + $args['first_name'], + $args['last_name'] + ), '{company}' => $args['company'], '{address_1}' => $args['address_1'], '{address_2}' => $args['address_2'], @@ -612,7 +617,14 @@ class WC_Countries { '{country}' => $full_country, '{first_name_upper}' => wc_strtoupper( $args['first_name'] ), '{last_name_upper}' => wc_strtoupper( $args['last_name'] ), - '{name_upper}' => wc_strtoupper( $args['first_name'] . ' ' . $args['last_name'] ), + '{name_upper}' => wc_strtoupper( + sprintf( + /* translators: 1: first name 2: last name */ + _x( '%1$s %2$s', 'full name', 'woocommerce' ), + $args['first_name'], + $args['last_name'] + ) + ), '{company_upper}' => wc_strtoupper( $args['company'] ), '{address_1_upper}' => wc_strtoupper( $args['address_1'] ), '{address_2_upper}' => wc_strtoupper( $args['address_2'] ), diff --git a/includes/class-wc-shipping-rate.php b/includes/class-wc-shipping-rate.php index 1248a70a423..72bb0686d56 100644 --- a/includes/class-wc-shipping-rate.php +++ b/includes/class-wc-shipping-rate.php @@ -162,7 +162,7 @@ class WC_Shipping_Rate { } /** - * Set ID for the rate. This is usually a combination of the method and instance IDs. + * Get ID for the rate. This is usually a combination of the method and instance IDs. * * @since 3.2.0 * @return string @@ -172,7 +172,7 @@ class WC_Shipping_Rate { } /** - * Set shipping method ID the rate belongs to. + * Get shipping method ID the rate belongs to. * * @since 3.2.0 * @return string @@ -182,7 +182,7 @@ class WC_Shipping_Rate { } /** - * Set instance ID the rate belongs to. + * Get instance ID the rate belongs to. * * @since 3.2.0 * @return int @@ -192,7 +192,7 @@ class WC_Shipping_Rate { } /** - * Set rate label. + * Get rate label. * * @return string */ @@ -201,7 +201,7 @@ class WC_Shipping_Rate { } /** - * Set rate cost. + * Get rate cost. * * @since 3.2.0 * @return string @@ -211,7 +211,7 @@ class WC_Shipping_Rate { } /** - * Set rate taxes. + * Get rate taxes. * * @since 3.2.0 * @return array diff --git a/includes/wc-template-functions.php b/includes/wc-template-functions.php index e6bcf282656..43597440f1c 100644 --- a/includes/wc-template-functions.php +++ b/includes/wc-template-functions.php @@ -2298,12 +2298,14 @@ if ( ! function_exists( 'woocommerce_checkout_coupon_form' ) ) { * Output the Coupon form for the checkout. */ function woocommerce_checkout_coupon_form() { - wc_get_template( - 'checkout/form-coupon.php', - array( - 'checkout' => WC()->checkout(), - ) - ); + if ( is_user_logged_in() || WC()->checkout()->is_registration_enabled() || ! WC()->checkout()->is_registration_required() ) { + wc_get_template( + 'checkout/form-coupon.php', + array( + 'checkout' => WC()->checkout(), + ) + ); + } } } diff --git a/templates/loop/pagination.php b/templates/loop/pagination.php index 9d61f8799cf..b524d19d14e 100644 --- a/templates/loop/pagination.php +++ b/templates/loop/pagination.php @@ -39,8 +39,8 @@ if ( $total <= 1 ) { 'add_args' => false, 'current' => max( 1, $current ), 'total' => $total, - 'prev_text' => '←', - 'next_text' => '→', + 'prev_text' => is_rtl() ? '→' : '←', + 'next_text' => is_rtl() ? '←' : '→', 'type' => 'list', 'end_size' => 3, 'mid_size' => 3, diff --git a/templates/single-product-reviews.php b/templates/single-product-reviews.php index 995a11ea348..4d6f12e5938 100644 --- a/templates/single-product-reviews.php +++ b/templates/single-product-reviews.php @@ -51,8 +51,8 @@ if ( ! comments_open() ) { apply_filters( 'woocommerce_comment_pagination_args', array( - 'prev_text' => '←', - 'next_text' => '→', + 'prev_text' => is_rtl() ? '→' : '←', + 'next_text' => is_rtl() ? '←' : '→', 'type' => 'list', ) ) diff --git a/tests/e2e/api/CHANGELOG.md b/tests/e2e/api/CHANGELOG.md index eba914c9a1a..0b14882e8e0 100644 --- a/tests/e2e/api/CHANGELOG.md +++ b/tests/e2e/api/CHANGELOG.md @@ -3,7 +3,8 @@ ## Added - Support for the external product type. -- Added support for grouped product type +- Support for grouped product type. +- Support for variable products and product variations. - Support for coupons. # 0.1.1 diff --git a/tests/e2e/api/src/framework/__tests__/model-repository.spec.ts b/tests/e2e/api/src/framework/__tests__/model-repository.spec.ts index ac3ed95f6a7..cae60a083ac 100644 --- a/tests/e2e/api/src/framework/__tests__/model-repository.spec.ts +++ b/tests/e2e/api/src/framework/__tests__/model-repository.spec.ts @@ -1,5 +1,6 @@ import { Model } from '../../models'; import { + CreatesChildModels, CreatesModels, DeletesChildModels, DeletesModels, @@ -90,7 +91,7 @@ describe( 'ModelRepository', () => { it( 'should create child', async () => { const model = new DummyChildModel(); const callback = jest.fn().mockResolvedValue( model ); - const repository: CreatesModels< DummyChildParams > = new ModelRepository< DummyChildParams >( + const repository: CreatesChildModels< DummyChildParams > = new ModelRepository< DummyChildParams >( null, callback, null, @@ -98,7 +99,7 @@ describe( 'ModelRepository', () => { null, ); - const created = await repository.create( { childName: 'test' } ); + const created = await repository.create( { parent: 'yes' }, { childName: 'test' } ); expect( created ).toBe( model ); expect( callback ).toHaveBeenCalledWith( { childName: 'test' } ); } ); diff --git a/tests/e2e/api/src/framework/model-repository.ts b/tests/e2e/api/src/framework/model-repository.ts index c4d9e4b68b6..ca598404b93 100644 --- a/tests/e2e/api/src/framework/model-repository.ts +++ b/tests/e2e/api/src/framework/model-repository.ts @@ -77,6 +77,20 @@ export type ListChildFn< T extends ModelRepositoryParams > = ( */ export type CreateFn< T extends ModelRepositoryParams > = ( properties: Partial< ModelClass< T > > ) => Promise< ModelClass< T > >; +/** + * A callback for creating a child model using a data source. + * + * @callback CreateChildFn + * @param {ModelID} parent The parent identifier for the model. + * @param {Partial.} properties The properties of the model to create. + * @return {Promise.} Resolves to the created model. + * @template {Model} T + */ +export type CreateChildFn< T extends ModelRepositoryParams > = ( + parent: ParentID< T >, + properties: Partial< ModelClass< T > > +) => Promise< ModelClass< T > >; + /** * A callback for reading a model using a data source. * @@ -189,6 +203,21 @@ export interface CreatesModels< T extends ModelRepositoryParams > { create( properties: Partial< ModelClass< T > > ): Promise< ModelClass< T > >; } +/** + * An interface for repositories that can create child models. + * + * @typedef CreatesChildModels + * @property {CreateChildFn.} create Creates a child model using the repository. + * @template {Model} T + * @template {ModelParentID} P + */ +export interface CreatesChildModels< T extends ModelRepositoryParams > { + create( + parent: HasParent< T, ParentID< T >, never >, + properties: HasParent< T, Partial< ModelClass< T > >, never >, + ): Promise< ModelClass< T > >; +} + /** * An interface for repositories that can read models. * @@ -301,7 +330,7 @@ export class ModelRepository< T extends ModelRepositoryParams > implements * @type {CreateFn.} * @private */ - private readonly createHook: CreateFn< T > | null; + private readonly createHook: HasParent< T, CreateChildFn< T >, CreateFn< T > > | null; /** * The hook used to read models. @@ -338,7 +367,7 @@ export class ModelRepository< T extends ModelRepositoryParams > implements */ public constructor( listHook: HasParent< T, ListChildFn< T >, ListFn< T > > | null, - createHook: CreateFn< T > | null, + createHook: HasParent< T, CreateChildFn< T >, CreateFn< T > > | null, readHook: HasParent< T, ReadChildFn< T >, ReadFn< T > > | null, updateHook: HasParent< T, UpdateChildFn< T >, UpdateFn< T > > | null, deleteHook: HasParent< T, DeleteChildFn< T >, DeleteFn > | null, @@ -380,15 +409,28 @@ export class ModelRepository< T extends ModelRepositoryParams > implements /** * Creates a new model using the repository. * + * @param {P|ModelID} propertiesOrParent The properties to create the model with or the model parent. * @param {Partial.} properties The properties to create the model with. * @return {Promise.} Resolves to the created model. */ - public create( properties: Partial< ModelClass< T > > ): Promise< ModelClass< T > > { + public create( + propertiesOrParent?: HasParent< T, ParentID< T >, Partial< ModelClass > >, + properties?: HasParent< T, Partial< ModelClass >, never >, + ): Promise< ModelClass< T > > { if ( ! this.createHook ) { throw new Error( 'The \'create\' operation is not supported on this model.' ); } - return this.createHook( properties ); + if ( properties === undefined ) { + return ( this.createHook as CreateFn< T > )( + propertiesOrParent as Partial< ModelClass >, + ); + } + + return ( this.createHook as CreateChildFn< T > )( + propertiesOrParent as ParentID, + properties as Partial< ModelClass >, + ); } /** diff --git a/tests/e2e/api/src/models/products/abstract/common.ts b/tests/e2e/api/src/models/products/abstract/common.ts index 9a87e20702e..88601fa6ad0 100644 --- a/tests/e2e/api/src/models/products/abstract/common.ts +++ b/tests/e2e/api/src/models/products/abstract/common.ts @@ -1,11 +1,10 @@ -import { Model, ModelID } from '../../model'; -import { MetaData, PostStatus } from '../../shared-types'; +import { AbstractProductData } from './data'; +import { ModelID } from '../../model'; import { CatalogVisibility, - ProductAttribute, - ProductImage, ProductTerm, ProductLinks, + ProductAttribute, } from '../shared'; /** @@ -31,7 +30,7 @@ export const buildProductURL = ( id: ModelID ) => baseProductURL() + id; /** * The base for all product types. */ -export abstract class AbstractProduct extends Model { +export abstract class AbstractProduct extends AbstractProductData { /** * The name of the product. * @@ -46,34 +45,6 @@ export abstract class AbstractProduct extends Model { */ public readonly slug: string = ''; - /** - * The permalink of the product. - * - * @type {string} - */ - public readonly permalink: string = ''; - - /** - * The Id of the product. - * - * @type {number} - */ - public readonly id: number = 0; - - /** - * The parent Id of the product. - * - * @type {number} - */ - public readonly parentId: number = 0; - - /** - * The menu order assigned to the product. - * - * @type {number} - */ - public readonly menuOrder: number = 0; - /** * The GMT datetime when the product was created. * @@ -88,13 +59,6 @@ export abstract class AbstractProduct extends Model { */ public readonly modified: Date = new Date(); - /** - * The product's current post status. - * - * @type {PostStatus} - */ - public readonly postStatus: PostStatus = ''; - /** * The product's short description. * @@ -102,20 +66,6 @@ export abstract class AbstractProduct extends Model { */ public readonly shortDescription: string = ''; - /** - * The product's full description. - * - * @type {string} - */ - public readonly description: string = ''; - - /** - * The product's SKU. - * - * @type {string} - */ - public readonly sku: string = ''; - /** * An array of the categories this product is in. * @@ -130,13 +80,6 @@ export abstract class AbstractProduct extends Model { */ public readonly tags: readonly ProductTerm[] = []; - /** - * Indicates whether or not the product is currently able to be purchased. - * - * @type {boolean} - */ - public readonly isPurchasable: boolean = true; - /** * Indicates whether or not the product should be featured. * @@ -144,20 +87,6 @@ export abstract class AbstractProduct extends Model { */ public readonly isFeatured: boolean = false; - /** - * The attributes for the product. - * - * @type {ReadonlyArray.} - */ - public readonly attributes: readonly ProductAttribute[] = []; - - /** - * The images for the product. - * - * @type {ReadonlyArray.} - */ - public readonly images: readonly ProductImage[] = []; - /** * Indicates whether or not the product should be visible in the catalog. * @@ -165,55 +94,6 @@ export abstract class AbstractProduct extends Model { */ public readonly catalogVisibility: CatalogVisibility = CatalogVisibility.Everywhere; - /** - * The current price of the product. - * - * @type {string} - */ - public readonly price: string = ''; - - /** - * The rendered HTML for the current price of the product. - * - * @type {string} - */ - public readonly priceHtml: string = ''; - - /** - * The regular price of the product when not discounted. - * - * @type {string} - */ - public readonly regularPrice: string = ''; - - /** - * Indicates whether or not the product is currently on sale. - * - * @type {boolean} - */ - public readonly onSale: boolean = false; - - /** - * The price of the product when on sale. - * - * @type {string} - */ - public readonly salePrice: string = ''; - - /** - * The GMT datetime when the product should start to be on sale. - * - * @type {Date|null} - */ - public readonly saleStart: Date | null = null; - - /** - * The GMT datetime when the product should no longer be on sale. - * - * @type {Date|null} - */ - public readonly saleEnd: Date | null = null; - /** * The count of sales of the product * @@ -250,11 +130,11 @@ export abstract class AbstractProduct extends Model { public readonly relatedIds: Array = []; /** - * The extra metadata for the product. + * The attributes for the product. * - * @type {ReadonlyArray.} + * @type {ReadonlyArray.} */ - public readonly metaData: readonly MetaData[] = []; + public readonly attributes: readonly ProductAttribute[] = []; /** * The products links. diff --git a/tests/e2e/api/src/models/products/abstract/data.ts b/tests/e2e/api/src/models/products/abstract/data.ts new file mode 100644 index 00000000000..0d1b83cb37e --- /dev/null +++ b/tests/e2e/api/src/models/products/abstract/data.ts @@ -0,0 +1,102 @@ +import { Model } from '../../model'; +import { MetaData, PostStatus } from '../../shared-types'; +import { ProductImage, ProductLinks } from '../shared'; + +/** + * Base product data. + */ +export abstract class AbstractProductData extends Model { + /** + * The permalink of the product. + * + * @type {string} + */ + public readonly permalink: string = ''; + + /** + * The Id of the product. + * + * @type {number} + */ + public readonly id: number = 0; + + /** + * The parent Id of the product. + * + * @type {number} + */ + public readonly parentId: number = 0; + + /** + * The menu order assigned to the product. + * + * @type {number} + */ + public readonly menuOrder: number = 0; + + /** + * The GMT datetime when the product was created. + * + * @type {Date} + */ + public readonly created: Date = new Date(); + + /** + * The GMT datetime when the product was last modified. + * + * @type {Date} + */ + public readonly modified: Date = new Date(); + + /** + * The product's current post status. + * + * @type {PostStatus} + */ + public readonly postStatus: PostStatus = ''; + + /** + * The product's full description. + * + * @type {string} + */ + public readonly description: string = ''; + + /** + * The product's SKU. + * + * @type {string} + */ + public readonly sku: string = ''; + + /** + * Indicates whether or not the product is currently able to be purchased. + * + * @type {boolean} + */ + public readonly isPurchasable: boolean = true; + + /** + * The images for the product. + * + * @type {ReadonlyArray.} + */ + public readonly images: readonly ProductImage[] = []; + + /** + * The extra metadata for the product. + * + * @type {ReadonlyArray.} + */ + public readonly metaData: readonly MetaData[] = []; + + /** + * The product data links. + * + * @type {ReadonlyArray.} + */ + public readonly links: ProductLinks = { + collection: [ { href: '' } ], + self: [ { href: '' } ], + }; +} diff --git a/tests/e2e/api/src/models/products/abstract/index.ts b/tests/e2e/api/src/models/products/abstract/index.ts index 893170420e3..85200971256 100644 --- a/tests/e2e/api/src/models/products/abstract/index.ts +++ b/tests/e2e/api/src/models/products/abstract/index.ts @@ -1,5 +1,6 @@ export * from './common'; export * from './cross-sell'; +export * from './data'; export * from './delivery'; export * from './external'; export * from './grouped'; diff --git a/tests/e2e/api/src/models/products/grouped-product.ts b/tests/e2e/api/src/models/products/grouped-product.ts index e44382c101c..9582685e874 100644 --- a/tests/e2e/api/src/models/products/grouped-product.ts +++ b/tests/e2e/api/src/models/products/grouped-product.ts @@ -40,7 +40,7 @@ export type GroupedProductRepositoryParams = * @typedef ListsGroupedProducts * @alias ListsModels. */ -export type ListsGroupedProducts = ListsModels< GroupedProductUpdateParams >; +export type ListsGroupedProducts = ListsModels< GroupedProductRepositoryParams >; /** * An interface for creating Grouped products using the repository. @@ -48,7 +48,7 @@ export type ListsGroupedProducts = ListsModels< GroupedProductUpdateParams >; * @typedef CreatesGroupedProducts * @alias CreatesModels. */ -export type CreatesGroupedProducts = CreatesModels< GroupedProductUpdateParams >; +export type CreatesGroupedProducts = CreatesModels< GroupedProductRepositoryParams >; /** * An interface for reading Grouped products using the repository. @@ -56,7 +56,7 @@ export type CreatesGroupedProducts = CreatesModels< GroupedProductUpdateParams > * @typedef ReadsGroupedProducts * @alias ReadsModels. */ -export type ReadsGroupedProducts = ReadsModels< GroupedProductUpdateParams >; +export type ReadsGroupedProducts = ReadsModels< GroupedProductRepositoryParams >; /** * An interface for updating Grouped products using the repository. @@ -64,7 +64,7 @@ export type ReadsGroupedProducts = ReadsModels< GroupedProductUpdateParams >; * @typedef UpdatesGroupedProducts * @alias UpdatesModels. */ -export type UpdatesGroupedProducts = UpdatesModels< GroupedProductUpdateParams >; +export type UpdatesGroupedProducts = UpdatesModels< GroupedProductRepositoryParams >; /** * An interface for deleting Grouped products using the repository. @@ -72,7 +72,7 @@ export type UpdatesGroupedProducts = UpdatesModels< GroupedProductUpdateParams > * @typedef DeletesGroupedProducts * @alias DeletesModels. */ -export type DeletesGroupedProducts = DeletesModels< GroupedProductUpdateParams >; +export type DeletesGroupedProducts = DeletesModels< GroupedProductRepositoryParams >; /** * The base for the Grouped product object. diff --git a/tests/e2e/api/src/models/products/index.ts b/tests/e2e/api/src/models/products/index.ts index 8d5dc6f2489..f3407a2afd2 100644 --- a/tests/e2e/api/src/models/products/index.ts +++ b/tests/e2e/api/src/models/products/index.ts @@ -3,3 +3,5 @@ export * from './shared'; export * from './simple-product'; export * from './grouped-product'; export * from './external-product'; +export * from './variation'; +export * from './variable-product'; diff --git a/tests/e2e/api/src/models/products/shared/classes.ts b/tests/e2e/api/src/models/products/shared/classes.ts index 562534f8b7a..7343c594352 100644 --- a/tests/e2e/api/src/models/products/shared/classes.ts +++ b/tests/e2e/api/src/models/products/shared/classes.ts @@ -70,9 +70,9 @@ export class ProductDownload { } /** - * A product's attributes. + * Attribute base class. */ -export class ProductAttribute { +export abstract class AbstractAttribute { /** * The ID of the attribute. * @@ -86,7 +86,12 @@ export class ProductAttribute { * @type {string} */ public readonly name: string = ''; +} +/** + * A product's attributes. + */ +export class ProductAttribute extends AbstractAttribute { /** * The sort order of the attribute. * @@ -121,6 +126,29 @@ export class ProductAttribute { * @param {Partial.} properties The properties to set. */ public constructor( properties?: Partial< ProductAttribute > ) { + super(); + Object.assign( this, properties ); + } +} + +/** + * Default attributes for variable products. + */ +export class ProductDefaultAttribute extends AbstractAttribute { + /** + * The option selected for the attribute. + * + * @type {string} + */ + public readonly option: string = ''; + + /** + * Creates a new product default attribute. + * + * @param {Partial.} properties The properties to set. + */ + public constructor( properties?: Partial< ProductDefaultAttribute > ) { + super(); Object.assign( this, properties ); } } @@ -220,6 +248,13 @@ export class ProductLinks { */ public readonly self: readonly ProductLinkItem[] = []; + /** + * The link to the parent. + * + * @type {ReadonlyArray.} + */ + public readonly up?: readonly ProductLinkItem[] = []; + /** * Creates a new product link list. * diff --git a/tests/e2e/api/src/models/products/shared/types.ts b/tests/e2e/api/src/models/products/shared/types.ts index 082d97032fc..d127181b445 100644 --- a/tests/e2e/api/src/models/products/shared/types.ts +++ b/tests/e2e/api/src/models/products/shared/types.ts @@ -6,14 +6,22 @@ */ export type StockStatus = 'instock' | 'outofstock' | 'onbackorder' | string +/** + * Base product properties. + */ +export type ProductDataUpdateParams = 'created' | 'postStatus' + | 'id' | 'permalink' | 'price' | 'priceHtml' + | 'description' | 'sku' | 'attributes' | 'images' + | 'regularPrice' | 'salePrice' | 'saleStart' | 'saleEnd' + | 'metaData' | 'menuOrder' | 'parentId' | 'links'; + /** * Properties common to all product types. */ -export type ProductCommonUpdateParams = 'name' | 'slug' | 'created' | 'postStatus' | 'shortDescription' - | 'id' | 'permalink' | 'type' | 'description' | 'sku' | 'categories' | 'tags' | 'isFeatured' - | 'attributes' | 'images' | 'catalogVisibility' | 'allowReviews' - | 'metaData' | 'menuOrder' | 'parentId' | 'relatedIds' | 'upsellIds' - | 'links' | 'relatedIds' | 'menuOrder' | 'parentId'; +export type ProductCommonUpdateParams = 'name' | 'slug' | 'shortDescription' + | 'categories' | 'tags' | 'isFeatured' | 'averageRating' | 'numRatings' + | 'catalogVisibility' | 'allowReviews' | 'upsellIds' | 'type' + & ProductDataUpdateParams; /** * Cross sells property. @@ -67,4 +75,4 @@ export type ProductDeliveryUpdateParams = 'daysToDownload' | 'downloadLimit' | ' /** * Properties exclusive to the Variable product type. */ -export type ProductVariableTypeUpdateParams = 'defaultAttributes' | 'variations'; +export type ProductVariableUpdateParams = 'defaultAttributes' | 'variations'; diff --git a/tests/e2e/api/src/models/products/variable-product.ts b/tests/e2e/api/src/models/products/variable-product.ts new file mode 100644 index 00000000000..6433502d8ac --- /dev/null +++ b/tests/e2e/api/src/models/products/variable-product.ts @@ -0,0 +1,173 @@ +import { + AbstractProduct, + IProductCommon, + IProductCrossSells, + IProductInventory, + IProductSalesTax, + IProductShipping, + IProductUpSells, + ProductSearchParams, +} from './abstract'; +import { + ProductInventoryUpdateParams, + ProductCommonUpdateParams, + ProductDefaultAttribute, + ProductSalesTaxUpdateParams, + ProductCrossUpdateParams, + ProductShippingUpdateParams, + ProductUpSellUpdateParams, + ProductVariableUpdateParams, + StockStatus, + BackorderStatus, + Taxability, +} from './shared'; +import { HTTPClient } from '../../http'; +import { variableProductRESTRepository } from '../../repositories'; +import { + CreatesModels, + DeletesModels, + ListsModels, + ModelRepositoryParams, + ReadsModels, + UpdatesModels, +} from '../../framework'; + +/** + * The parameters that variable products can update. + */ +type VariableProductUpdateParams = ProductVariableUpdateParams + & ProductCommonUpdateParams + & ProductCrossUpdateParams + & ProductInventoryUpdateParams + & ProductSalesTaxUpdateParams + & ProductShippingUpdateParams + & ProductUpSellUpdateParams; +/** + * The parameters embedded in this generic can be used in the ModelRepository in order to give + * type-safety in an incredibly granular way. + */ +export type VariableProductRepositoryParams = + ModelRepositoryParams< VariableProduct, never, ProductSearchParams, VariableProductUpdateParams >; + +/** + * An interface for listing variable products using the repository. + * + * @typedef ListsVariableProducts + * @alias ListsModels. + */ +export type ListsVariableProducts = ListsModels< VariableProductRepositoryParams >; + +/** + * An interface for creating variable products using the repository. + * + * @typedef CreatesVariableProducts + * @alias CreatesModels. + */ +export type CreatesVariableProducts = CreatesModels< VariableProductRepositoryParams >; + +/** + * An interface for reading variable products using the repository. + * + * @typedef ReadsVariableProducts + * @alias ReadsModels. + */ +export type ReadsVariableProducts = ReadsModels< VariableProductRepositoryParams >; + +/** + * An interface for updating variable products using the repository. + * + * @typedef UpdatesVariableProducts + * @alias UpdatesModels. + */ +export type UpdatesVariableProducts = UpdatesModels< VariableProductRepositoryParams >; + +/** + * An interface for deleting variable products using the repository. + * + * @typedef DeletesVariableProducts + * @alias DeletesModels. + */ +export type DeletesVariableProducts = DeletesModels< VariableProductRepositoryParams >; + +/** + * The base for the Variable product object. + */ +export class VariableProduct extends AbstractProduct implements + IProductCommon, + IProductCrossSells, + IProductInventory, + IProductSalesTax, + IProductShipping, + IProductUpSells { + /** + * @see ./abstracts/cross-sells.ts + */ + public readonly crossSellIds: Array = []; + + /** + * @see ./abstracts/upsell.ts + */ + public readonly upSellIds: Array = []; + + /** + * @see ./abstracts/inventory.ts + */ + public readonly onePerOrder: boolean = false; + public readonly trackInventory: boolean = false; + public readonly remainingStock: number = -1; + public readonly stockStatus: StockStatus = '' + public readonly backorderStatus: BackorderStatus = BackorderStatus.Allowed; + public readonly canBackorder: boolean = false; + public readonly isOnBackorder: boolean = false; + + /** + * @see ./abstracts/sales-tax.ts + */ + public readonly taxStatus: Taxability = Taxability.ProductAndShipping; + public readonly taxClass: string = ''; + + /** + * @see ./abstracts/shipping.ts + */ + public readonly weight: string = ''; + public readonly length: string = ''; + public readonly width: string = ''; + public readonly height: string = ''; + public readonly requiresShipping: boolean = false; + public readonly isShippingTaxable: boolean = false; + public readonly shippingClass: string = ''; + public readonly shippingClassId: number = 0; + + /** + * Default product attributes. + * + * @type {ReadonlyArray.} + */ + public readonly defaultAttributes: readonly ProductDefaultAttribute[] = []; + + /** + * Product variations. + * + * @type {ReadonlyArray.} + */ + public readonly variations: Array = []; + + /** + * Creates a new Variable product instance with the given properties + * + * @param {Object} properties The properties to set in the object. + */ + public constructor( properties?: Partial< VariableProduct > ) { + super(); + Object.assign( this, properties ); + } + + /** + * Creates a model repository configured for communicating via the REST API. + * + * @param {HTTPClient} httpClient The client for communicating via HTTP. + */ + public static restRepository( httpClient: HTTPClient ): ReturnType< typeof variableProductRESTRepository > { + return variableProductRESTRepository( httpClient ); + } +} diff --git a/tests/e2e/api/src/models/products/variation.ts b/tests/e2e/api/src/models/products/variation.ts new file mode 100644 index 00000000000..78e8580c2fa --- /dev/null +++ b/tests/e2e/api/src/models/products/variation.ts @@ -0,0 +1,187 @@ +import { ModelID } from '../model'; +import { + AbstractProductData, + IProductDelivery, + IProductInventory, + IProductPrice, + IProductSalesTax, + IProductShipping, + ProductSearchParams, +} from './abstract'; +import { + ProductDataUpdateParams, + ProductDeliveryUpdateParams, + ProductInventoryUpdateParams, + ProductPriceUpdateParams, + ProductSalesTaxUpdateParams, + ProductShippingUpdateParams, + ProductLinks, + Taxability, + ProductDownload, + StockStatus, + BackorderStatus, + ProductDefaultAttribute, +} from './shared'; +import { + CreatesChildModels, + DeletesChildModels, + ListsChildModels, + ModelRepositoryParams, + ReadsChildModels, + UpdatesChildModels, +} from '../../framework'; +import { HTTPClient } from '../../http'; +import { productVariationRESTRepository } from '../../repositories'; + +/** + * The parameters that product variations can update. + */ +type ProductVariationUpdateParams = ProductDataUpdateParams + & ProductDeliveryUpdateParams + & ProductInventoryUpdateParams + & ProductPriceUpdateParams + & ProductSalesTaxUpdateParams + & ProductShippingUpdateParams; +/** + * The parameters embedded in this generic can be used in the ModelRepository in order to give + * type-safety in an incredibly granular way. + */ +export type ProductVariationRepositoryParams = + ModelRepositoryParams< ProductVariation, ModelID, ProductSearchParams, ProductVariationUpdateParams >; + +/** + * An interface for listing variable products using the repository. + * + * @typedef ListsProductVariations + * @alias ListsModels. + */ +export type ListsProductVariations = ListsChildModels< ProductVariationRepositoryParams >; + +/** + * An interface for creating variable products using the repository. + * + * @typedef CreatesProductVariations + * @alias CreatesModels. + */ +export type CreatesProductVariations = CreatesChildModels< ProductVariationRepositoryParams >; + +/** + * An interface for reading variable products using the repository. + * + * @typedef ReadsProductVariations + * @alias ReadsModels. + */ +export type ReadsProductVariations = ReadsChildModels< ProductVariationRepositoryParams >; + +/** + * An interface for updating variable products using the repository. + * + * @typedef UpdatesProductVariations + * @alias UpdatesModels. + */ +export type UpdatesProductVariations = UpdatesChildModels< ProductVariationRepositoryParams >; + +/** + * An interface for deleting variable products using the repository. + * + * @typedef DeletesProductVariations + * @alias DeletesModels. + */ +export type DeletesProductVariations = DeletesChildModels< ProductVariationRepositoryParams >; + +/** + * The base for the product variation object. + */ +export class ProductVariation extends AbstractProductData implements + IProductDelivery, + IProductInventory, + IProductPrice, + IProductSalesTax, + IProductShipping { + /** + * @see ./abstracts/delivery.ts + */ + public readonly isVirtual: boolean = false; + public readonly isDownloadable: boolean = false; + public readonly downloads: readonly ProductDownload[] = []; + public readonly downloadLimit: number = -1; + public readonly daysToDownload: number = -1; + public readonly purchaseNote: string = ''; + + /** + * @see ./abstracts/inventory.ts + */ + public readonly onePerOrder: boolean = false; + public readonly trackInventory: boolean = false; + public readonly remainingStock: number = -1; + public readonly stockStatus: StockStatus = '' + public readonly backorderStatus: BackorderStatus = BackorderStatus.Allowed; + public readonly canBackorder: boolean = false; + public readonly isOnBackorder: boolean = false; + + /** + * @see ./abstracts/price.ts + */ + public readonly price: string = ''; + public readonly priceHtml: string = ''; + public readonly regularPrice: string = ''; + public readonly onSale: boolean = false; + public readonly salePrice: string = ''; + public readonly saleStart: Date | null = null; + public readonly saleEnd: Date | null = null; + + /** + * @see ./abstracts/sales-tax.ts + */ + public readonly taxStatus: Taxability = Taxability.ProductAndShipping; + public readonly taxClass: string = ''; + + /** + * @see ./abstracts/shipping.ts + */ + public readonly weight: string = ''; + public readonly length: string = ''; + public readonly width: string = ''; + public readonly height: string = ''; + public readonly requiresShipping: boolean = false; + public readonly isShippingTaxable: boolean = false; + public readonly shippingClass: string = ''; + public readonly shippingClassId: number = 0; + + /** + * The variation links. + * + * @type {ReadonlyArray.} + */ + public readonly links: ProductLinks = { + collection: [ { href: '' } ], + self: [ { href: '' } ], + up: [ { href: '' } ], + }; + + /** + * The attributes for the variation. + * + * @type {ReadonlyArray.} + */ + public readonly attributes: readonly ProductDefaultAttribute[] = []; + + /** + * Creates a new product variation instance with the given properties + * + * @param {Object} properties The properties to set in the object. + */ + public constructor( properties?: Partial< ProductVariation > ) { + super(); + Object.assign( this, properties ); + } + + /** + * Creates a model repository configured for communicating via the REST API. + * + * @param {HTTPClient} httpClient The client for communicating via HTTP. + */ + public static restRepository( httpClient: HTTPClient ): ReturnType< typeof productVariationRESTRepository > { + return productVariationRESTRepository( httpClient ); + } +} diff --git a/tests/e2e/api/src/repositories/rest/products/index.ts b/tests/e2e/api/src/repositories/rest/products/index.ts index d6e7313328b..33d0d78a505 100644 --- a/tests/e2e/api/src/repositories/rest/products/index.ts +++ b/tests/e2e/api/src/repositories/rest/products/index.ts @@ -2,10 +2,14 @@ import { createProductTransformer } from './shared'; import { groupedProductRESTRepository } from './grouped-product'; import { simpleProductRESTRepository } from './simple-product'; import { externalProductRESTRepository } from './external-product'; +import { variableProductRESTRepository } from './variable-product'; +import { productVariationRESTRepository } from './variation'; export { createProductTransformer, externalProductRESTRepository, groupedProductRESTRepository, simpleProductRESTRepository, + variableProductRESTRepository, + productVariationRESTRepository, }; diff --git a/tests/e2e/api/src/repositories/rest/products/shared.ts b/tests/e2e/api/src/repositories/rest/products/shared.ts index 5c049872e2e..8105a270734 100644 --- a/tests/e2e/api/src/repositories/rest/products/shared.ts +++ b/tests/e2e/api/src/repositories/rest/products/shared.ts @@ -12,6 +12,7 @@ import { } from '../../../framework'; import { AbstractProduct, + AbstractProductData, IProductCrossSells, IProductDelivery, IProductExternal, @@ -26,6 +27,7 @@ import { ProductDownload, ProductImage, ProductTerm, + VariableProduct, } from '../../../models'; import { createMetaDataTransformer } from '../shared'; @@ -110,6 +112,55 @@ function createProductDownloadTransformer(): ModelTransformer< ProductDownload > ); } +/** + * Creates a transformer for the base product property data. + * + * @param {Array.} transformations Optional transformers to add to the transformer. + * @return {ModelTransformer} The created transformer. + */ +export function createProductDataTransformer< T extends AbstractProductData >( + transformations?: ModelTransformation[], +): ModelTransformer< T > { + if ( ! transformations ) { + transformations = []; + } + + transformations.push( + new IgnorePropertyTransformation( + [ + 'date_created', + 'date_modified', + ], + ), + new ModelTransformerTransformation( 'images', ProductImage, createProductImageTransformer() ), + new ModelTransformerTransformation( 'metaData', MetaData, createMetaDataTransformer() ), + new PropertyTypeTransformation( + { + created: PropertyType.Date, + modified: PropertyType.Date, + isPurchasable: PropertyType.Boolean, + parentId: PropertyType.Integer, + menuOrder: PropertyType.Integer, + permalink: PropertyType.String, + }, + ), + new KeyChangeTransformation< AbstractProductData >( + { + created: 'date_created_gmt', + modified: 'date_modified_gmt', + postStatus: 'status', + isPurchasable: 'purchasable', + metaData: 'meta_data', + parentId: 'parent_id', + menuOrder: 'menu_order', + links: '_links', + }, + ), + ); + + return new ModelTransformer( transformations ); +} + /** * Creates a transformer for the shared properties of all products. * @@ -127,56 +178,34 @@ export function createProductTransformer< T extends AbstractProduct >( transformations.push( new AddPropertyTransformation( {}, { type } ), - new IgnorePropertyTransformation( - [ - 'date_created', - 'date_modified', - ], - ), new ModelTransformerTransformation( 'categories', ProductTerm, createProductTermTransformer() ), new ModelTransformerTransformation( 'tags', ProductTerm, createProductTermTransformer() ), new ModelTransformerTransformation( 'attributes', ProductAttribute, createProductAttributeTransformer() ), - new ModelTransformerTransformation( 'images', ProductImage, createProductImageTransformer() ), - new ModelTransformerTransformation( 'metaData', MetaData, createMetaDataTransformer() ), new PropertyTypeTransformation( { - created: PropertyType.Date, - modified: PropertyType.Date, - isPurchasable: PropertyType.Boolean, isFeatured: PropertyType.Boolean, allowReviews: PropertyType.Boolean, averageRating: PropertyType.Integer, numRatings: PropertyType.Integer, totalSales: PropertyType.Integer, - parentId: PropertyType.Integer, - menuOrder: PropertyType.Integer, - permalink: PropertyType.String, relatedIds: PropertyType.Integer, }, ), new KeyChangeTransformation< AbstractProduct >( { - created: 'date_created_gmt', - modified: 'date_modified_gmt', - postStatus: 'status', shortDescription: 'short_description', - isPurchasable: 'purchasable', isFeatured: 'featured', catalogVisibility: 'catalog_visibility', allowReviews: 'reviews_allowed', averageRating: 'average_rating', numRatings: 'rating_count', - metaData: 'meta_data', totalSales: 'total_sales', - parentId: 'parent_id', - menuOrder: 'menu_order', relatedIds: 'related_ids', - links: '_links', }, ), ); - return new ModelTransformer( transformations ); + return createProductDataTransformer< T >( transformations ); } /** @@ -412,6 +441,29 @@ export function createProductShippingTransformation(): ModelTransformation[] { return transformations; } +/** + * Variable product specific properties transformations + */ +export function createProductVariableTransformation(): ModelTransformation[] { + const transformations = [ + new PropertyTypeTransformation( + { + id: PropertyType.Integer, + name: PropertyType.String, + option: PropertyType.String, + variations: PropertyType.Integer, + }, + ), + new KeyChangeTransformation< VariableProduct >( + { + defaultAttributes: 'default_attributes', + }, + ), + ]; + + return transformations; +} + /** * Transformer for the properties unique to the external product type. */ @@ -433,4 +485,3 @@ export function createProductExternalTransformation(): ModelTransformation[] { return transformations; } - diff --git a/tests/e2e/api/src/repositories/rest/products/variable-product.ts b/tests/e2e/api/src/repositories/rest/products/variable-product.ts new file mode 100644 index 00000000000..9133b4ccdee --- /dev/null +++ b/tests/e2e/api/src/repositories/rest/products/variable-product.ts @@ -0,0 +1,72 @@ +import { HTTPClient } from '../../../http'; +import { ModelRepository } from '../../../framework'; +import { + VariableProduct, + CreatesVariableProducts, + DeletesVariableProducts, + ListsVariableProducts, + ReadsVariableProducts, + VariableProductRepositoryParams, + UpdatesVariableProducts, + baseProductURL, + buildProductURL, +} from '../../../models'; +import { + createProductTransformer, + createProductCrossSellsTransformation, + createProductInventoryTransformation, + createProductSalesTaxTransformation, + createProductShippingTransformation, + createProductUpSellsTransformation, + createProductVariableTransformation, +} from './shared'; +import { + restCreate, + restDelete, + restList, + restRead, + restUpdate, +} from '../shared'; + +/** + * Creates a new ModelRepository instance for interacting with models via the REST API. + * + * @param {HTTPClient} httpClient The HTTP client for the REST requests to be made using. + * @return { + * ListsVariableProducts| + * CreatesVariableProducts| + * ReadsVariableProducts| + * UpdatesVariableProducts| + * DeletesVariableProducts + * } The created repository. + */ +export function variableProductRESTRepository( httpClient: HTTPClient ): ListsVariableProducts + & CreatesVariableProducts + & ReadsVariableProducts + & UpdatesVariableProducts + & DeletesVariableProducts { + const crossSells = createProductCrossSellsTransformation(); + const inventory = createProductInventoryTransformation(); + const salesTax = createProductSalesTaxTransformation(); + const shipping = createProductShippingTransformation(); + const upsells = createProductUpSellsTransformation(); + const variable = createProductVariableTransformation(); + const transformations = [ + ...crossSells, + ...inventory, + ...salesTax, + ...shipping, + ...upsells, + ...variable, + ]; + + const transformer = createProductTransformer( 'variable', transformations ); + + return new ModelRepository( + restList< VariableProductRepositoryParams >( baseProductURL, VariableProduct, httpClient, transformer ), + restCreate< VariableProductRepositoryParams >( baseProductURL, VariableProduct, httpClient, transformer ), + restRead< VariableProductRepositoryParams >( buildProductURL, VariableProduct, httpClient, transformer ), + restUpdate< VariableProductRepositoryParams >( buildProductURL, VariableProduct, httpClient, transformer ), + restDelete< VariableProductRepositoryParams >( buildProductURL, httpClient ), + ); +} diff --git a/tests/e2e/api/src/repositories/rest/products/variation.ts b/tests/e2e/api/src/repositories/rest/products/variation.ts new file mode 100644 index 00000000000..896d6f1bc8f --- /dev/null +++ b/tests/e2e/api/src/repositories/rest/products/variation.ts @@ -0,0 +1,73 @@ +import { HTTPClient } from '../../../http'; +import { ModelRepository } from '../../../framework'; +import { + ProductVariation, + ModelID, + CreatesProductVariations, + DeletesProductVariations, + ListsProductVariations, + ReadsProductVariations, + ProductVariationRepositoryParams, + UpdatesProductVariations, + buildProductURL, +} from '../../../models'; +import { + createProductDataTransformer, + createProductDeliveryTransformation, + createProductInventoryTransformation, + createProductPriceTransformation, + createProductSalesTaxTransformation, + createProductShippingTransformation, +} from './shared'; +import { + restCreateChild, + restDeleteChild, + restListChild, + restReadChild, + restUpdateChild, +} from '../shared'; + +/** + * Creates a new ModelRepository instance for interacting with models via the REST API. + * + * @param {HTTPClient} httpClient The HTTP client for the REST requests to be made using. + * @return { + * ListsProductVariations| + * CreatesProductVariations| + * ReadsProductVariations| + * UpdatesProductVariations| + * DeletesProductVariations + * } The created repository. + */ +export function productVariationRESTRepository( httpClient: HTTPClient ): ListsProductVariations + & CreatesProductVariations + & ReadsProductVariations + & UpdatesProductVariations + & DeletesProductVariations { + const buildURL = ( parent: ModelID ) => buildProductURL( parent ) + '/variations/'; + const buildChildURL = ( parent: ModelID, id: ModelID ) => buildURL( parent ) + id; + const buildDeleteURL = ( parent: ModelID, id: ModelID ) => buildChildURL( parent, id ) + '?force=true'; + + const delivery = createProductDeliveryTransformation(); + const inventory = createProductInventoryTransformation(); + const price = createProductPriceTransformation(); + const salesTax = createProductSalesTaxTransformation(); + const shipping = createProductShippingTransformation(); + const transformations = [ + ...delivery, + ...inventory, + ...price, + ...salesTax, + ...shipping, + ]; + + const transformer = createProductDataTransformer( transformations ); + + return new ModelRepository( + restListChild< ProductVariationRepositoryParams >( buildURL, ProductVariation, httpClient, transformer ), + restCreateChild< ProductVariationRepositoryParams >( buildURL, ProductVariation, httpClient, transformer ), + restReadChild< ProductVariationRepositoryParams >( buildChildURL, ProductVariation, httpClient, transformer ), + restUpdateChild< ProductVariationRepositoryParams >( buildChildURL, ProductVariation, httpClient, transformer ), + restDeleteChild< ProductVariationRepositoryParams >( buildDeleteURL, httpClient ), + ); +} diff --git a/tests/e2e/api/src/repositories/rest/shared.ts b/tests/e2e/api/src/repositories/rest/shared.ts index 61d3c55de7b..1d52d73ec3d 100644 --- a/tests/e2e/api/src/repositories/rest/shared.ts +++ b/tests/e2e/api/src/repositories/rest/shared.ts @@ -13,6 +13,7 @@ import { UpdateChildFn, DeleteChildFn, CreateFn, + CreateChildFn, ModelTransformer, IgnorePropertyTransformation, // @ts-ignore @@ -138,6 +139,31 @@ export function restCreate< T extends ModelRepositoryParams >( }; } +/** + * Creates a callback for creating child models using the REST API. + * + * @param {Function} buildURL A callback to build the URL. (This is passed the properties for the new model.) + * @param {Function} modelClass The model we're listing. + * @param {HTTPClient} httpClient The HTTP client to use for the request. + * @param {ModelTransformer} transformer The transformer to use for the response data. + * @return {CreateChildFn} The callback for the repository. + */ +export function restCreateChild< T extends ModelRepositoryParams >( + buildURL: ( parent: ParentID< T >, properties: Partial< ModelClass< T > > ) => string, + modelClass: ModelConstructor< ModelClass< T > >, + httpClient: HTTPClient, + transformer: ModelTransformer< ModelClass< T > >, +): CreateChildFn< T > { + return async ( parent, properties ) => { + const response = await httpClient.post( + buildURL( parent, properties ), + transformer.fromModel( properties ), + ); + + return Promise.resolve( transformer.toModel( modelClass, response.data ) ); + }; +} + /** * Creates a callback for reading models using the REST API. * diff --git a/tests/e2e/config/default.json b/tests/e2e/config/default.json index 5f1a336107c..4d6b397aac2 100644 --- a/tests/e2e/config/default.json +++ b/tests/e2e/config/default.json @@ -1,6 +1,5 @@ { "url": "http://localhost:8084/", - "appName": "woocommerce_e2e", "users": { "admin": { "username": "admin", @@ -16,8 +15,98 @@ "name": "Simple product" }, "variable": { - "name": "Variable Product with Three Variations" + "name": "Variable Product with Three Attributes", + "defaultAttributes": [ + { + "id": 0, + "name": "Size", + "option": "Medium" + }, + { + "id": 0, + "name": "Colour", + "option": "Blue" + } + ], + "attributes": [ + { + "id": 0, + "name": "Colour", + "isVisibleOnProductPage": true, + "isForVariations": true, + "options": [ + "Red", + "Green", + "Blue" + ], + "sortOrder": 0 + }, + { + "id": 0, + "name": "Size", + "isVisibleOnProductPage": true, + "isForVariations": true, + "options": [ + "Small", + "Medium", + "Large" + ], + "sortOrder": 0 + }, + { + "id": 0, + "name": "Logo", + "isVisibleOnProductPage": true, + "isForVariations": true, + "options": [ + "Woo", + "WordPress" + ], + "sortOrder": 0 + } + ] }, + "variations": [ + { + "regularPrice": "19.99", + "attributes": [ + { + "name": "Size", + "option": "Large" + }, + { + "name": "Colour", + "option": "Red" + } + ] + }, + { + "regularPrice": "18.99", + "attributes": [ + { + "name": "Size", + "option": "Medium" + }, + { + "name": "Colour", + "option": "Green" + } + ] + }, + { + "regularPrice": "17.99", + "attributes": [ + { + "name": "Size", + "option": "Small" + }, + { + "name": "Colour", + "option": "Blue" + } + ] + } + ], "grouped": { "name": "Grouped Product with Three Children", "groupedProducts": [ diff --git a/tests/e2e/core-tests/CHANGELOG.md b/tests/e2e/core-tests/CHANGELOG.md index 760a7d464e1..22b98b2991e 100644 --- a/tests/e2e/core-tests/CHANGELOG.md +++ b/tests/e2e/core-tests/CHANGELOG.md @@ -1,5 +1,12 @@ # Unreleased +## Added + +- api package test for variable products and product variations +- api package test for grouped products +- api package test for external products +- api package test for coupons + # 0.1.1 ## Added @@ -14,6 +21,7 @@ - Shopper Single Product tests - Shopper My Account Pay Order - Shopper Checkout Apply Coupon +- Shopper Shop Browse Search Sort - Merchant Orders Customer Checkout Page - Shopper Cart Apply Coupon - Shopper Variable product info updates on different variations diff --git a/tests/e2e/core-tests/README.md b/tests/e2e/core-tests/README.md index ebe07209dab..427f0157258 100644 --- a/tests/e2e/core-tests/README.md +++ b/tests/e2e/core-tests/README.md @@ -71,6 +71,7 @@ The functions to access the core tests are: - `runMyAccountPayOrderTest` - Shopper can pay for his order in My Account - `runCartApplyCouponsTest` - Shopper can apply coupons in the cart - `runCheckoutApplyCouponsTest` - Shopper can apply coupons in the checkout + - `runProductBrowseSearchSortTest` - Shopper can browse, search & sort products - `runVariableProductUpdateTest` - Shopper can view and update variations on a variable product ## Contributing a new test diff --git a/tests/e2e/core-tests/specs/api/grouped-product.test.js b/tests/e2e/core-tests/specs/api/grouped-product.test.js index df3c06d0914..11dea1ba7a8 100644 --- a/tests/e2e/core-tests/specs/api/grouped-product.test.js +++ b/tests/e2e/core-tests/specs/api/grouped-product.test.js @@ -57,7 +57,7 @@ const runGroupedProductAPITest = () => { expect( product ).toEqual( expect.objectContaining( baseGroupedProduct ) ); }); - it('can retrieve a raw external product', async () => { + it('can retrieve a raw grouped product', async () => { let rawProperties = { id: product.id, grouped_products: baseGroupedProduct.groupedProducts, diff --git a/tests/e2e/core-tests/specs/api/variable-product.test.js b/tests/e2e/core-tests/specs/api/variable-product.test.js new file mode 100644 index 00000000000..3fcea7a8792 --- /dev/null +++ b/tests/e2e/core-tests/specs/api/variable-product.test.js @@ -0,0 +1,92 @@ +/* eslint-disable jest/no-export, jest/no-disabled-tests */ +/** + * Internal dependencies + */ +const { HTTPClientFactory, VariableProduct, ProductVariation } = require( '@woocommerce/api' ); + +/** + * External dependencies + */ +const config = require( 'config' ); +const { + it, + describe, + beforeAll, +} = require( '@jest/globals' ); + +/** + * Create a variable product and retrieve via the API. + */ +const runVariableProductAPITest = () => { + describe('REST API > Variable Product', () => { + let client; + let defaultVariableProduct; + let defaultVariations; + let baseVariableProduct; + let product; + let variations = []; + let productRepository; + let variationRepository; + + beforeAll(async () => { + defaultVariableProduct = config.get('products.variable'); + defaultVariations = config.get('products.variations'); + const admin = config.get('users.admin'); + const url = config.get('url'); + + client = HTTPClientFactory.build(url) + .withBasicAuth(admin.username, admin.password) + .withIndexPermalinks() + .create(); + }); + + it('can create a variable product', async () => { + productRepository = VariableProduct.restRepository(client); + + // Check properties of product in the create product response. + product = await productRepository.create(defaultVariableProduct); + expect(product).toEqual(expect.objectContaining(defaultVariableProduct)); + }); + + it('can add variations', async () => { + variationRepository = ProductVariation.restRepository(client); + for (let v = 0; v < defaultVariations.length; v++) { + const variation = await variationRepository.create(product.id, defaultVariations[v]); + // Test that variation id is a number. + expect(variation.id).toBeGreaterThan(0); + variations.push(variation.id); + } + + baseVariableProduct = { + id: product.id, + ...defaultVariableProduct, + variations, + }; + }); + + it('can retrieve a transformed variable product', async () => { + // Read product via the repository. + const transformed = await productRepository.read(product.id); + expect(transformed).toEqual(expect.objectContaining(baseVariableProduct)); + }); + + it('can retrieve transformed product variations', async () => { + // Read variations via the repository. + const transformed = await variationRepository.list(product.id); + expect(transformed).toHaveLength(defaultVariations.length); + }); + + it('can delete a variation', async () => { + const variationId = baseVariableProduct.variations.pop(); + const status = variationRepository.delete(product.id, variationId); + expect(status).toBeTruthy(); + }); + + it('can delete a variable product', async () => { + const status = productRepository.delete(product.id); + expect(status).toBeTruthy(); + }); + }); +} + +module.exports = runVariableProductAPITest; diff --git a/tests/e2e/core-tests/specs/index.js b/tests/e2e/core-tests/specs/index.js index 663eaf9073a..c3f46ad4d9b 100644 --- a/tests/e2e/core-tests/specs/index.js +++ b/tests/e2e/core-tests/specs/index.js @@ -9,6 +9,7 @@ const { runOnboardingFlowTest, runTaskListTest } = require( './activate-and-setu const runInitialStoreSettingsTest = require( './activate-and-setup/setup.test' ); // Shopper tests +const runProductBrowseSearchSortTest = require( './shopper/front-end-product-browse-search-sort.test' ); const runCartApplyCouponsTest = require( './shopper/front-end-cart-coupons.test'); const runCartPageTest = require( './shopper/front-end-cart.test' ); const runCheckoutApplyCouponsTest = require( './shopper/front-end-checkout-coupons.test'); @@ -37,6 +38,7 @@ const runMerchantOrdersCustomerPaymentPage = require( './merchant/wp-admin-order const runExternalProductAPITest = require( './api/external-product.test' ); const runCouponApiTest = require( './api/coupon.test' ); const runGroupedProductAPITest = require( './api/grouped-product.test' ); +const runVariableProductAPITest = require( './api/variable-product.test' ); const runSetupOnboardingTests = () => { runActivationTest(); @@ -46,6 +48,7 @@ const runSetupOnboardingTests = () => { }; const runShopperTests = () => { + runProductBrowseSearchSortTest(); runCartApplyCouponsTest(); runCartPageTest(); runCheckoutApplyCouponsTest(); @@ -75,6 +78,7 @@ const runMerchantTests = () => { const runApiTests = () => { runExternalProductAPITest(); + runVariableProductAPITest(); runCouponApiTest(); } @@ -86,6 +90,7 @@ module.exports = { runSetupOnboardingTests, runExternalProductAPITest, runGroupedProductAPITest, + runVariableProductAPITest, runCouponApiTest, runCartApplyCouponsTest, runCartPageTest, @@ -111,5 +116,6 @@ module.exports = { runProductSearchTest, runMerchantOrdersCustomerPaymentPage, runMerchantTests, + runProductBrowseSearchSortTest, runApiTests, }; diff --git a/tests/e2e/core-tests/specs/shopper/front-end-cart-coupons.test.js b/tests/e2e/core-tests/specs/shopper/front-end-cart-coupons.test.js index 4b3188a7e13..b2b3f4888a3 100644 --- a/tests/e2e/core-tests/specs/shopper/front-end-cart-coupons.test.js +++ b/tests/e2e/core-tests/specs/shopper/front-end-cart-coupons.test.js @@ -8,7 +8,8 @@ const { createCoupon, createSimpleProduct, uiUnblocked, - clearAndFillInput, + applyCoupon, + removeCoupon, } = require( '@woocommerce/e2e-utils' ); /** @@ -20,28 +21,6 @@ const { beforeAll, } = require( '@jest/globals' ); -/** - * Apply a coupon code to the cart. - * - * @param couponCode string - * @returns {Promise} - */ -const applyCouponToCart = async ( couponCode ) => { - await clearAndFillInput('#coupon_code', couponCode); - await expect(page).toClick('button', {text: 'Apply coupon'}); - await uiUnblocked(); -}; - -/** - * Remove one coupon from the cart. - * - * @returns {Promise} - */ -const removeCouponFromCart = async () => { - await expect(page).toClick('.woocommerce-remove-coupon', {text: '[Remove]'}); - await uiUnblocked(); - await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon has been removed.'}); -} const runCartApplyCouponsTest = () => { describe('Cart applying coupons', () => { let couponFixedCart; @@ -62,42 +41,42 @@ const runCartApplyCouponsTest = () => { }); it('allows customer to apply fixed cart coupon', async () => { - await applyCouponToCart( couponFixedCart ); + await applyCoupon(couponFixedCart); await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon code applied successfully.'}); // Verify discount applied and order total await page.waitForSelector('.order-total'); await expect(page).toMatchElement('.cart-discount .amount', {text: '$5.00'}); await expect(page).toMatchElement('.order-total .amount', {text: '$4.99'}); - await removeCouponFromCart(); + await removeCoupon(couponFixedCart); }); it('allows customer to apply percentage coupon', async () => { - await applyCouponToCart( couponPercentage ); + await applyCoupon(couponPercentage); await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon code applied successfully.'}); // Verify discount applied and order total await page.waitForSelector('.order-total'); await expect(page).toMatchElement('.cart-discount .amount', {text: '$4.99'}); await expect(page).toMatchElement('.order-total .amount', {text: '$5.00'}); - await removeCouponFromCart(); + await removeCoupon(couponPercentage); }); it('allows customer to apply fixed product coupon', async () => { - await applyCouponToCart( couponFixedProduct ); + await applyCoupon(couponFixedProduct); await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon code applied successfully.'}); // Verify discount applied and order total await page.waitForSelector('.order-total'); await expect(page).toMatchElement('.cart-discount .amount', {text: '$5.00'}); await expect(page).toMatchElement('.order-total .amount', {text: '$4.99'}); - await removeCouponFromCart(); + await removeCoupon(couponFixedProduct); }); it('prevents customer applying same coupon twice', async () => { - await applyCouponToCart( couponFixedCart ); + await applyCoupon(couponFixedCart); await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon code applied successfully.'}); - await applyCouponToCart( couponFixedCart ); + await applyCoupon(couponFixedCart); // Verify only one discount applied // This is a work around for Puppeteer inconsistently finding 'Coupon code already applied' await expect(page).toMatchElement('.cart-discount .amount', {text: '$5.00'}); @@ -105,7 +84,7 @@ const runCartApplyCouponsTest = () => { }); it('allows customer to apply multiple coupons', async () => { - await applyCouponToCart( couponFixedProduct ); + await applyCoupon(couponFixedProduct); await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon code applied successfully.'}); // Verify discount applied and order total @@ -114,8 +93,8 @@ const runCartApplyCouponsTest = () => { }); it('restores cart total when coupons are removed', async () => { - await removeCouponFromCart(); - await removeCouponFromCart(); + await removeCoupon(couponFixedCart); + await removeCoupon(couponFixedProduct); await expect(page).toMatchElement('.order-total .amount', {text: '$9.99'}); }); }); diff --git a/tests/e2e/core-tests/specs/shopper/front-end-checkout-coupons.test.js b/tests/e2e/core-tests/specs/shopper/front-end-checkout-coupons.test.js index abe006ef6d0..e017c6d1739 100644 --- a/tests/e2e/core-tests/specs/shopper/front-end-checkout-coupons.test.js +++ b/tests/e2e/core-tests/specs/shopper/front-end-checkout-coupons.test.js @@ -8,7 +8,8 @@ const { createCoupon, createSimpleProduct, uiUnblocked, - clearAndFillInput, + applyCoupon, + removeCoupon, } = require( '@woocommerce/e2e-utils' ); /** @@ -20,30 +21,6 @@ const { beforeAll, } = require( '@jest/globals' ); -/** - * Apply a coupon code to the cart. - * - * @param couponCode string - * @returns {Promise} - */ -const applyCouponToCart = async ( couponCode ) => { - await expect(page).toClick('a', {text: 'Click here to enter your code'}); - await uiUnblocked(); - await clearAndFillInput('#coupon_code', couponCode); - await expect(page).toClick('button', {text: 'Apply coupon'}); - await uiUnblocked(); -}; - -/** - * Remove one coupon from the cart. - * - * @returns {Promise} - */ -const removeCouponFromCart = async () => { - await expect(page).toClick('.woocommerce-remove-coupon', {text: '[Remove]'}); - await uiUnblocked(); - await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon has been removed.'}); -} const runCheckoutApplyCouponsTest = () => { describe('Checkout coupons', () => { let couponFixedCart; @@ -64,7 +41,7 @@ const runCheckoutApplyCouponsTest = () => { }); it('allows customer to apply fixed cart coupon', async () => { - await applyCouponToCart( couponFixedCart ); + await applyCoupon(couponFixedCart); await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon code applied successfully.'}); // Wait for page to expand total calculations to avoid flakyness @@ -73,31 +50,31 @@ const runCheckoutApplyCouponsTest = () => { // Verify discount applied and order total await expect(page).toMatchElement('.cart-discount .amount', {text: '$5.00'}); await expect(page).toMatchElement('.order-total .amount', {text: '$4.99'}); - await removeCouponFromCart(); + await removeCoupon(couponFixedCart); }); it('allows customer to apply percentage coupon', async () => { - await applyCouponToCart( couponPercentage ); + await applyCoupon(couponPercentage); await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon code applied successfully.'}); // Verify discount applied and order total await expect(page).toMatchElement('.cart-discount .amount', {text: '$4.99'}); await expect(page).toMatchElement('.order-total .amount', {text: '$5.00'}); - await removeCouponFromCart(); + await removeCoupon(couponPercentage); }); it('allows customer to apply fixed product coupon', async () => { - await applyCouponToCart( couponFixedProduct ); + await applyCoupon(couponFixedProduct); await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon code applied successfully.'}); await expect(page).toMatchElement('.cart-discount .amount', {text: '$5.00'}); await expect(page).toMatchElement('.order-total .amount', {text: '$4.99'}); - await removeCouponFromCart(); + await removeCoupon(couponFixedProduct); }); it('prevents customer applying same coupon twice', async () => { - await applyCouponToCart( couponFixedCart ); + await applyCoupon(couponFixedCart); await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon code applied successfully.'}); - await applyCouponToCart( couponFixedCart ); + await applyCoupon(couponFixedCart); // Verify only one discount applied // This is a work around for Puppeteer inconsistently finding 'Coupon code already applied' await expect(page).toMatchElement('.cart-discount .amount', {text: '$5.00'}); @@ -105,14 +82,14 @@ const runCheckoutApplyCouponsTest = () => { }); it('allows customer to apply multiple coupons', async () => { - await applyCouponToCart( couponFixedProduct ); + await applyCoupon(couponFixedProduct); await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon code applied successfully.'}); await expect(page).toMatchElement('.order-total .amount', {text: '$0.00'}); }); it('restores cart total when coupons are removed', async () => { - await removeCouponFromCart(); - await removeCouponFromCart(); + await removeCoupon(couponFixedCart); + await removeCoupon(couponFixedProduct); await expect(page).toMatchElement('.order-total .amount', {text: '$9.99'}); }); }); diff --git a/tests/e2e/core-tests/specs/shopper/front-end-product-browse-search-sort.test.js b/tests/e2e/core-tests/specs/shopper/front-end-product-browse-search-sort.test.js new file mode 100644 index 00000000000..c852cf1996e --- /dev/null +++ b/tests/e2e/core-tests/specs/shopper/front-end-product-browse-search-sort.test.js @@ -0,0 +1,87 @@ +/* eslint-disable jest/no-export, jest/no-disabled-tests */ +/** + * Internal dependencies + */ +const { + shopper, + merchant, + createSimpleProductWithCategory, + uiUnblocked, +} = require( '@woocommerce/e2e-utils' ); + +/** + * External dependencies + */ +const { + it, + describe, + beforeAll, +} = require( '@jest/globals' ); + +const config = require( 'config' ); +const simpleProductName = config.get( 'products.simple.name' ); +const singleProductPrice = config.has('products.simple.price') ? config.get('products.simple.price') : '9.99'; +const singleProductPrice2 = config.has('products.simple.price') ? config.get('products.simple.price') : '19.99'; +const singleProductPrice3 = config.has('products.simple.price') ? config.get('products.simple.price') : '29.99'; +const clothing = 'Clothing'; +const audio = 'Audio'; +const hardware = 'Hardware'; +const productTitle = 'li.first > a > h2.woocommerce-loop-product__title'; + +const runProductBrowseSearchSortTest = () => { + describe('Search, browse by categories and sort items in the shop', () => { + beforeAll(async () => { + await merchant.login(); + // Create 1st product with Clothing category + await createSimpleProductWithCategory(simpleProductName + ' 1', singleProductPrice, clothing); + // Create 2nd product with Audio category + await createSimpleProductWithCategory(simpleProductName + ' 2', singleProductPrice2, audio); + // Create 3rd product with Hardware category + await createSimpleProductWithCategory(simpleProductName + ' 3', singleProductPrice3, hardware); + await merchant.logout(); + }); + + it('should let user search the store', async () => { + await shopper.goToShop(); + await shopper.searchForProduct(simpleProductName + ' 1'); + }); + + it('should let user browse products by categories', async () => { + // Browse through Clothing category link + await Promise.all([ + page.waitForNavigation({waitUntil: 'networkidle0'}), + page.click('span.posted_in > a', {text: clothing}), + ]); + await uiUnblocked(); + + // Verify Clothing category page + await page.waitForSelector(productTitle); + await expect(page).toMatchElement(productTitle, {text: simpleProductName + ' 1'}); + await expect(page).toClick(productTitle, {text: simpleProductName + ' 1'}); + await uiUnblocked(); + await page.waitForSelector('h1.entry-title'); + await expect(page).toMatchElement('h1.entry-title', simpleProductName + ' 1'); + }); + + it('should let user sort the products in the shop', async () => { + await shopper.goToShop(); + + // Sort by price high to low + await page.select('.orderby', 'price-desc'); + // Verify the first product in sort order + await expect(page).toMatchElement(productTitle, {text: simpleProductName + ' 3'}); + + // Sort by price low to high + await page.select('.orderby', 'price'); + // Verify the first product in sort order + await expect(page).toMatchElement(productTitle, {text: simpleProductName + ' 1'}); + + // Sort by date of creation, latest to oldest + await page.select('.orderby', 'date'); + // Verify the first product in sort order + await expect(page).toMatchElement(productTitle, {text: simpleProductName + ' 3'}); + }); + }); +}; + +module.exports = runProductBrowseSearchSortTest; diff --git a/tests/e2e/specs/front-end/test-product-browse-search-sort.js b/tests/e2e/specs/front-end/test-product-browse-search-sort.js new file mode 100644 index 00000000000..ed12d1cb341 --- /dev/null +++ b/tests/e2e/specs/front-end/test-product-browse-search-sort.js @@ -0,0 +1,6 @@ +/* + * Internal dependencies + */ +const { runProductBrowseSearchSortTest } = require( '@woocommerce/e2e-core-tests' ); + +runProductBrowseSearchSortTest(); diff --git a/tests/e2e/specs/rest-api/variable-product.js b/tests/e2e/specs/rest-api/variable-product.js new file mode 100644 index 00000000000..9561f630a76 --- /dev/null +++ b/tests/e2e/specs/rest-api/variable-product.js @@ -0,0 +1,6 @@ +/* + * Internal dependencies + */ +const { runVariableProductAPITest } = require( '@woocommerce/e2e-core-tests' ); + +runVariableProductAPITest(); diff --git a/tests/e2e/utils/CHANGELOG.md b/tests/e2e/utils/CHANGELOG.md index 3bb027f92f7..2a38d03bb3f 100644 --- a/tests/e2e/utils/CHANGELOG.md +++ b/tests/e2e/utils/CHANGELOG.md @@ -17,6 +17,9 @@ - `createCoupon( couponAmount )` component which accepts a coupon amount string (it defaults to 5) and creates a basic coupon. Returns the generated coupon code. - `evalAndClick( selector )` use Puppeteer page.$eval to select and click and element. - `selectOptionInSelect2( selector, value )` util helper method that search and select in any select2 type field +- `createSimpleProductWithCategory` component which creates a simple product with categories, containing three parameters for title, price and category name. +- `applyCoupon( couponName )` util helper method which applies previously created coupon to cart or checkout +- `removeCoupon()` util helper method that removes a single coupon within cart or checkout ## Changes diff --git a/tests/e2e/utils/README.md b/tests/e2e/utils/README.md index 12f814aa992..747d4fb081b 100644 --- a/tests/e2e/utils/README.md +++ b/tests/e2e/utils/README.md @@ -77,6 +77,7 @@ describe( 'Cart page', () => { | `productIsInCheckout` | `productTitle, quantity, total, cartSubtotal` | Verify product is in cart on checkout page | | `removeFromCart` | `productTitle` | Remove a product from the cart on the cart page | | `setCartQuantity` | `productTitle, quantityValue` | Change the quantity of a product on the cart page | +| `searchForProduct` | Searching for a product name and landing on its detail page | ### Page Utilities @@ -102,6 +103,8 @@ describe( 'Cart page', () => { | `moveAllItemsToTrash` | | Moves all items in a list view to the Trash | | `verifyAndPublish` | `noticeText` | Verify that an item can be published | | `selectOptionInSelect2` | `selector, value` | helper method that searchs for select2 type fields and select plus insert value inside +| `applyCoupon` | `couponName` | helper method which applies a coupon in cart or checkout +| `removeCoupon` | | helper method that removes a single coupon within cart or checkout ### Test Utilities diff --git a/tests/e2e/utils/src/components.js b/tests/e2e/utils/src/components.js index 04a7b4a3e02..e72ae11d2b0 100644 --- a/tests/e2e/utils/src/components.js +++ b/tests/e2e/utils/src/components.js @@ -184,6 +184,44 @@ const createSimpleProduct = async () => { return product.id; } ; +/** + * Create simple product with categories + * + * @param productName Product's name which can be changed when writing a test + * @param productPrice Product's price which can be changed when writing a test + * @param categoryName Product's category which can be changed when writing a test + */ +const createSimpleProductWithCategory = async ( productName, productPrice, categoryName ) => { + // Go to "add product" page + await merchant.openNewProduct(); + + // Add title and regular price + await expect(page).toFill('#title', productName); + await expect(page).toClick('#_virtual'); + await clickTab('General'); + await expect(page).toFill('#_regular_price', productPrice); + + // Try to select the existing category if present already, otherwise add a new and select it + try { + const [checkbox] = await page.$x('//label[contains(text(), "'+categoryName+'")]'); + await checkbox.click(); + } catch (error) { + await expect(page).toClick('#product_cat-add-toggle'); + await expect(page).toFill('#newproduct_cat', categoryName); + await expect(page).toClick('#product_cat-add-submit'); + } + + // Publish the product + await expect(page).toClick('#publish'); + await uiUnblocked(); + await page.waitForSelector('.updated.notice', {text:'Product published.'}); + + // Get the product ID + const variablePostId = await page.$('#post_ID'); + let variablePostIdValue = (await(await variablePostId.getProperty('value')).jsonValue()); + return variablePostIdValue; +}; + /** * Create variable product. */ @@ -444,4 +482,5 @@ export { verifyAndPublish, addProductToOrder, createCoupon, + createSimpleProductWithCategory, }; diff --git a/tests/e2e/utils/src/flows/shopper.js b/tests/e2e/utils/src/flows/shopper.js index 516a69e4df6..8e4acad61f2 100644 --- a/tests/e2e/utils/src/flows/shopper.js +++ b/tests/e2e/utils/src/flows/shopper.js @@ -137,6 +137,17 @@ const shopper = { await quantityInput.type( quantityValue.toString() ); }, + searchForProduct: async ( prouductName ) => { + await expect(page).toFill('.search-field', prouductName); + await expect(page).toClick('.search-submit'); + await page.waitForSelector('h2.entry-title'); + await expect(page).toMatchElement('h2.entry-title', {text: prouductName}); + await expect(page).toClick('h2.entry-title', {text: prouductName}); + await page.waitForSelector('h1.entry-title'); + await expect(page.title()).resolves.toMatch(prouductName); + await expect(page).toMatchElement('h1.entry-title', prouductName); + }, + /* * My Accounts flows. */ diff --git a/tests/e2e/utils/src/page-utils.js b/tests/e2e/utils/src/page-utils.js index 7679c15f2b7..9ab8c7b3aa5 100644 --- a/tests/e2e/utils/src/page-utils.js +++ b/tests/e2e/utils/src/page-utils.js @@ -209,6 +209,39 @@ const selectOptionInSelect2 = async ( value, selector = 'input.select2-search__f await page.keyboard.press('Enter'); }; +/** + * Apply a coupon code within cart or checkout. + * Method will try to apply a coupon in the checkout, otherwise will try to apply in the cart. + * + * @param couponCode string + * @returns {Promise} + */ +const applyCoupon = async ( couponCode ) => { + try { + await expect(page).toClick('a', {text: 'Click here to enter your code'}); + await uiUnblocked(); + await clearAndFillInput('#coupon_code', couponCode); + await expect(page).toClick('button', {text: 'Apply coupon'}); + await uiUnblocked(); + } catch (error) { + await clearAndFillInput('#coupon_code', couponCode); + await expect(page).toClick('button', {text: 'Apply coupon'}); + await uiUnblocked(); + }; +}; + +/** + * Remove one coupon within cart or checkout. + * + * @param couponCode Coupon name. + * @returns {Promise} + */ +const removeCoupon = async ( couponCode ) => { + await expect(page).toClick('[data-coupon="'+couponCode.toLowerCase()+'"]', {text: '[Remove]'}); + await uiUnblocked(); + await expect(page).toMatchElement('.woocommerce-message', {text: 'Coupon has been removed.'}); +}; + export { clearAndFillInput, clickTab, @@ -225,4 +258,6 @@ export { moveAllItemsToTrash, evalAndClick, selectOptionInSelect2, + applyCoupon, + removeCoupon, };