Merge branch 'trunk' into e2e-shopper-pay-order

This commit is contained in:
Veljko V 2021-03-04 21:51:32 +01:00 committed by GitHub
commit f44c2fa549
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1276 additions and 262 deletions

View File

@ -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'] ),

View File

@ -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

View File

@ -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(),
)
);
}
}
}

View File

@ -39,8 +39,8 @@ if ( $total <= 1 ) {
'add_args' => false,
'current' => max( 1, $current ),
'total' => $total,
'prev_text' => '&larr;',
'next_text' => '&rarr;',
'prev_text' => is_rtl() ? '&rarr;' : '&larr;',
'next_text' => is_rtl() ? '&larr;' : '&rarr;',
'type' => 'list',
'end_size' => 3,
'mid_size' => 3,

View File

@ -51,8 +51,8 @@ if ( ! comments_open() ) {
apply_filters(
'woocommerce_comment_pagination_args',
array(
'prev_text' => '&larr;',
'next_text' => '&rarr;',
'prev_text' => is_rtl() ? '&rarr;' : '&larr;',
'next_text' => is_rtl() ? '&larr;' : '&rarr;',
'type' => 'list',
)
)

View File

@ -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

View File

@ -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' } );
} );

View File

@ -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.<T>} properties The properties of the model to create.
* @return {Promise.<T>} 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.<T>} 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.<T>}
* @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.<T>} properties The properties to create the model with.
* @return {Promise.<T>} Resolves to the created model.
*/
public create( properties: Partial< ModelClass< T > > ): Promise< ModelClass< T > > {
public create(
propertiesOrParent?: HasParent< T, ParentID< T >, Partial< ModelClass<T> > >,
properties?: HasParent< T, Partial< ModelClass<T> >, 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<T> >,
);
}
return ( this.createHook as CreateChildFn< T > )(
propertiesOrParent as ParentID<T>,
properties as Partial< ModelClass<T> >,
);
}
/**

View File

@ -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.<ProductAttribute>}
*/
public readonly attributes: readonly ProductAttribute[] = [];
/**
* The images for the product.
*
* @type {ReadonlyArray.<ProductImage>}
*/
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<number> = [];
/**
* The extra metadata for the product.
* The attributes for the product.
*
* @type {ReadonlyArray.<MetaData>}
* @type {ReadonlyArray.<ProductAttribute>}
*/
public readonly metaData: readonly MetaData[] = [];
public readonly attributes: readonly ProductAttribute[] = [];
/**
* The products links.

View File

@ -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.<ProductImage>}
*/
public readonly images: readonly ProductImage[] = [];
/**
* The extra metadata for the product.
*
* @type {ReadonlyArray.<MetaData>}
*/
public readonly metaData: readonly MetaData[] = [];
/**
* The product data links.
*
* @type {ReadonlyArray.<ProductLinks>}
*/
public readonly links: ProductLinks = {
collection: [ { href: '' } ],
self: [ { href: '' } ],
};
}

View File

@ -1,5 +1,6 @@
export * from './common';
export * from './cross-sell';
export * from './data';
export * from './delivery';
export * from './external';
export * from './grouped';

View File

@ -40,7 +40,7 @@ export type GroupedProductRepositoryParams =
* @typedef ListsGroupedProducts
* @alias ListsModels.<GroupedProduct>
*/
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.<GroupedProduct>
*/
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.<GroupedProduct>
*/
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.<GroupedProduct>
*/
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.<GroupedProduct>
*/
export type DeletesGroupedProducts = DeletesModels< GroupedProductUpdateParams >;
export type DeletesGroupedProducts = DeletesModels< GroupedProductRepositoryParams >;
/**
* The base for the Grouped product object.

View File

@ -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';

View File

@ -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.<ProductAttribute>} 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.<ProductDefaultAttribute>} 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.<ProductLinkItem>}
*/
public readonly up?: readonly ProductLinkItem[] = [];
/**
* Creates a new product link list.
*

View File

@ -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';

View File

@ -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.<VariableProduct>
*/
export type ListsVariableProducts = ListsModels< VariableProductRepositoryParams >;
/**
* An interface for creating variable products using the repository.
*
* @typedef CreatesVariableProducts
* @alias CreatesModels.<VariableProduct>
*/
export type CreatesVariableProducts = CreatesModels< VariableProductRepositoryParams >;
/**
* An interface for reading variable products using the repository.
*
* @typedef ReadsVariableProducts
* @alias ReadsModels.<VariableProduct>
*/
export type ReadsVariableProducts = ReadsModels< VariableProductRepositoryParams >;
/**
* An interface for updating variable products using the repository.
*
* @typedef UpdatesVariableProducts
* @alias UpdatesModels.<VariableProduct>
*/
export type UpdatesVariableProducts = UpdatesModels< VariableProductRepositoryParams >;
/**
* An interface for deleting variable products using the repository.
*
* @typedef DeletesVariableProducts
* @alias DeletesModels.<VariableProduct>
*/
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<number> = [];
/**
* @see ./abstracts/upsell.ts
*/
public readonly upSellIds: Array<number> = [];
/**
* @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.<ProductDefaultAttribute>}
*/
public readonly defaultAttributes: readonly ProductDefaultAttribute[] = [];
/**
* Product variations.
*
* @type {ReadonlyArray.<number>}
*/
public readonly variations: Array<number> = [];
/**
* 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 );
}
}

View File

@ -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.<ProductVariation>
*/
export type ListsProductVariations = ListsChildModels< ProductVariationRepositoryParams >;
/**
* An interface for creating variable products using the repository.
*
* @typedef CreatesProductVariations
* @alias CreatesModels.<ProductVariation>
*/
export type CreatesProductVariations = CreatesChildModels< ProductVariationRepositoryParams >;
/**
* An interface for reading variable products using the repository.
*
* @typedef ReadsProductVariations
* @alias ReadsModels.<ProductVariation>
*/
export type ReadsProductVariations = ReadsChildModels< ProductVariationRepositoryParams >;
/**
* An interface for updating variable products using the repository.
*
* @typedef UpdatesProductVariations
* @alias UpdatesModels.<ProductVariation>
*/
export type UpdatesProductVariations = UpdatesChildModels< ProductVariationRepositoryParams >;
/**
* An interface for deleting variable products using the repository.
*
* @typedef DeletesProductVariations
* @alias DeletesModels.<ProductVariation>
*/
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.<ProductLinks>}
*/
public readonly links: ProductLinks = {
collection: [ { href: '' } ],
self: [ { href: '' } ],
up: [ { href: '' } ],
};
/**
* The attributes for the variation.
*
* @type {ReadonlyArray.<ProductDefaultAttribute>}
*/
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 );
}
}

View File

@ -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,
};

View File

@ -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.<ModelTransformation>} 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;
}

View File

@ -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<VariableProduct>( '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 ),
);
}

View File

@ -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<ProductVariation>( 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 ),
);
}

View File

@ -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.
*

View File

@ -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": [

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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;

View File

@ -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,
};

View File

@ -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<void>}
*/
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<void>}
*/
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'});
});
});

View File

@ -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<void>}
*/
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<void>}
*/
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'});
});
});

View File

@ -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;

View File

@ -0,0 +1,6 @@
/*
* Internal dependencies
*/
const { runProductBrowseSearchSortTest } = require( '@woocommerce/e2e-core-tests' );
runProductBrowseSearchSortTest();

View File

@ -0,0 +1,6 @@
/*
* Internal dependencies
*/
const { runVariableProductAPITest } = require( '@woocommerce/e2e-core-tests' );
runVariableProductAPITest();

View File

@ -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

View File

@ -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

View File

@ -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,
};

View File

@ -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.
*/

View File

@ -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<void>}
*/
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<void>}
*/
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,
};