This commit is contained in:
vedanshujain 2020-08-06 14:06:29 +05:30
commit 222852dea6
45 changed files with 12896 additions and 1926 deletions

View File

@ -8,12 +8,12 @@
"minimum-stability": "dev",
"require": {
"php": ">=7.0",
"automattic/jetpack-autoloader": "^2.0.2",
"automattic/jetpack-constants": "^1.1",
"automattic/jetpack-autoloader": "2.0.2",
"automattic/jetpack-constants": "1.4.0",
"composer/installers": "1.7.0",
"league/container": "^3.3",
"league/container": "3.3.1",
"maxmind-db/reader": "1.6.0",
"pelago/emogrifier": "^3.1",
"pelago/emogrifier": "3.1.0",
"woocommerce/action-scheduler": "3.1.6",
"woocommerce/woocommerce-admin": "1.4.0-beta.3",
"woocommerce/woocommerce-blocks": "3.1.0",

83
composer.lock generated
View File

@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d7df64252352cb3445d827cf204f24b4",
"content-hash": "d90fdd441ed3eebf7b0bb77b5304b616",
"packages": [
{
"name": "automattic/jetpack-autoloader",
"version": "v2.1.0",
"version": "v2.0.2",
"source": {
"type": "git",
"url": "https://github.com/Automattic/jetpack-autoloader.git",
"reference": "802517b3ff3010de89141d9f7c4d56aec1d21527"
"reference": "4502da4b2443fc1b61389cacc94c34876aca2b3d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Automattic/jetpack-autoloader/zipball/802517b3ff3010de89141d9f7c4d56aec1d21527",
"reference": "802517b3ff3010de89141d9f7c4d56aec1d21527",
"url": "https://api.github.com/repos/Automattic/jetpack-autoloader/zipball/4502da4b2443fc1b61389cacc94c34876aca2b3d",
"reference": "4502da4b2443fc1b61389cacc94c34876aca2b3d",
"shasum": ""
},
"require": {
@ -40,7 +40,7 @@
"GPL-2.0-or-later"
],
"description": "Creates a custom autoloader for a plugin or theme.",
"time": "2020-07-27T20:37:00+00:00"
"time": "2020-07-09T13:18:38+00:00"
},
{
"name": "automattic/jetpack-constants",
@ -259,6 +259,12 @@
"provider",
"service"
],
"funding": [
{
"url": "https://github.com/philipobenito",
"type": "github"
}
],
"time": "2020-05-18T08:20:23+00:00"
},
{
@ -495,6 +501,20 @@
],
"description": "Symfony CssSelector Component",
"homepage": "https://symfony.com",
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2020-03-16T08:31:04+00:00"
},
{
@ -788,6 +808,20 @@
"constructor",
"instantiate"
],
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
"type": "tidelift"
}
],
"time": "2020-05-29T17:27:14+00:00"
},
{
@ -1050,6 +1084,12 @@
"object",
"object graph"
],
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
}
],
"time": "2020-06-29T13:22:24+00:00"
},
{
@ -2574,6 +2614,20 @@
],
"description": "Symfony Finder Component",
"homepage": "https://symfony.com",
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2020-02-14T07:34:21+00:00"
},
{
@ -2636,6 +2690,20 @@
"polyfill",
"portable"
],
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2020-07-14T12:35:20+00:00"
},
{
@ -3041,5 +3109,6 @@
"platform-dev": [],
"platform-overrides": {
"php": "7.1"
}
},
"plugin-api-version": "1.1.0"
}

View File

@ -9,6 +9,15 @@
defined( 'ABSPATH' ) || exit;
return array(
'AT' => array(
'currency_code' => 'EUR',
'currency_pos' => 'left',
'thousand_sep' => '.',
'decimal_sep' => ',',
'num_decimals' => 2,
'weight_unit' => 'kg',
'dimension_unit' => 'cm',
),
'AU' => array(
'currency_code' => 'AUD',
'currency_pos' => 'left',
@ -54,6 +63,15 @@ return array(
'weight_unit' => 'kg',
'dimension_unit' => 'cm',
),
'CH' => array(
'currency_code' => 'CHF',
'currency_pos' => 'left_space',
'thousand_sep' => "'",
'decimal_sep' => '.',
'num_decimals' => 2,
'weight_unit' => 'kg',
'dimension_unit' => 'cm',
),
'DE' => array(
'currency_code' => 'EUR',
'currency_pos' => 'left',
@ -135,6 +153,15 @@ return array(
'weight_unit' => 'kg',
'dimension_unit' => 'cm',
),
'LI' => array(
'currency_code' => 'CHF',
'currency_pos' => 'left_space',
'thousand_sep' => "'",
'decimal_sep' => '.',
'num_decimals' => 2,
'weight_unit' => 'kg',
'dimension_unit' => 'cm',
),
'MD' => array(
'currency_code' => 'MDL',
'currency_pos' => 'right_space',

View File

@ -3,7 +3,7 @@
* WC Order Item Shipping Data Store
*
* @version 3.0.0
* @package data-stores
* @package WooCommerce\DataStores
*/
if ( ! defined( 'ABSPATH' ) ) {

8123
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@
"scripts": {
"build": "grunt && npm run makepot && npm run build:packages",
"build-watch": "grunt watch",
"build:packages": "node ./tests/e2e/bin/build.js",
"build:packages": "lerna run build",
"build:zip": "./bin/build-zip.sh",
"lint:js": "eslint assets/js --ext=js",
"docker:up": "npm explore @woocommerce/e2e-environment -- npm run docker:up",
@ -21,7 +21,7 @@
"test:e2e-dev": "npm explore @woocommerce/e2e-environment -- npm run test:e2e-dev",
"makepot": "composer run-script makepot",
"packages:fix:textdomain": "node ./bin/package-update-textdomain.js",
"publish-packages": "npm run build:packages && lerna publish from-package",
"publish-packages": "lerna publish from-package",
"git:update-hooks": "rm -r .git/hooks && mkdir -p .git/hooks && node ./node_modules/husky/husky.js install"
},
"devDependencies": {
@ -30,12 +30,16 @@
"@babel/polyfill": "7.10.4",
"@babel/preset-env": "7.10.4",
"@babel/register": "7.10.4",
"@jest/test-sequencer": "^25.0.0",
"@jest/test-sequencer": "25.1.0",
"@typescript-eslint/eslint-plugin": "3.1.0",
"@typescript-eslint/parser": "3.1.0",
"@woocommerce/e2e-environment": "file:tests/e2e/env",
"@woocommerce/model-factories": "file:tests/e2e/factories",
"@wordpress/babel-plugin-import-jsx-pragma": "1.1.3",
"@wordpress/babel-preset-default": "3.0.2",
"@wordpress/e2e-test-utils": "4.6.0",
"autoprefixer": "9.8.4",
"@wordpress/eslint-plugin": "7.1.0",
"autoprefixer": "9.8.6",
"babel-eslint": "10.1.0",
"chai": "4.2.0",
"chai-as-promised": "7.1.1",
@ -47,7 +51,7 @@
"eslint-config-wpcalypso": "5.0.0",
"eslint-plugin-jest": "23.19.0",
"github-contributors-list": "https://github.com/woocommerce/github-contributors-list/tarball/master",
"grunt": "1.1.0",
"grunt": "1.2.1",
"grunt-contrib-clean": "2.0.0",
"grunt-contrib-concat": "1.0.1",
"grunt-contrib-copy": "1.0.0",
@ -68,11 +72,12 @@
"lint-staged": "9.5.0",
"mocha": "7.2.0",
"node-sass": "4.13.0",
"prettier": "github:automattic/calypso-prettier#c56b4251",
"prettier": "npm:wp-prettier@^2.0.5",
"puppeteer": "2.0.0",
"puppeteer-utils": "github:Automattic/puppeteer-utils#0f3ec50",
"stylelint": "12.0.1",
"stylelint-config-wordpress": "16.0.0",
"typescript": "3.9.5",
"webpack": "4.41.6",
"webpack-cli": "3.3.11",
"wp-textdomain": "^1.0.1"
@ -100,6 +105,10 @@
"*.js": [
"eslint --fix",
"git add"
],
"*.ts": [
"eslint --fix",
"git add"
]
},
"browserslist": [

View File

@ -19,13 +19,12 @@ const deasync = require( 'deasync' );
/**
* Internal dependencies
*/
const getPackages = require( './get-packages' );
const getBabelConfig = require( './get-babel-config' );
/**
* Module Constants
*/
const PACKAGES_DIR = path.resolve( __dirname, '../' );
const PACKAGE_DIR = process.cwd();
const SRC_DIR = 'src';
const BUILD_DIR = {
main: 'build',
@ -33,16 +32,6 @@ const BUILD_DIR = {
};
const DONE = chalk.reset.inverse.bold.green( ' DONE ' );
/**
* Get the package name for a specified file
*
* @param {string} file File name
* @return {string} Package name
*/
function getPackageName( file ) {
return path.relative( PACKAGES_DIR, file ).split( path.sep )[ 0 ];
}
const isJsFile = ( filepath ) => {
return /.\.js$/.test( filepath );
};
@ -55,9 +44,8 @@ const isJsFile = ( filepath ) => {
* @return {string} Build path
*/
function getBuildPath( file, buildFolder ) {
const pkgName = getPackageName( file );
const pkgSrcPath = path.resolve( PACKAGES_DIR, pkgName, SRC_DIR );
const pkgBuildPath = path.resolve( PACKAGES_DIR, pkgName, buildFolder );
const pkgSrcPath = path.resolve( PACKAGE_DIR, SRC_DIR );
const pkgBuildPath = path.resolve( PACKAGE_DIR, buildFolder );
const relativeToSrcPath = path.relative( pkgSrcPath, file );
return path.resolve( pkgBuildPath, relativeToSrcPath );
}
@ -121,9 +109,9 @@ function buildJsFileFor( file, silent, environment ) {
if ( ! silent ) {
process.stdout.write(
chalk.green( ' \u2022 ' ) +
path.relative( PACKAGES_DIR, file ) +
path.relative( PACKAGE_DIR, file ) +
chalk.green( ' \u21D2 ' ) +
path.relative( PACKAGES_DIR, destPath ) +
path.relative( PACKAGE_DIR, destPath ) +
'\n'
);
}
@ -136,6 +124,15 @@ function buildJsFileFor( file, silent, environment ) {
*/
function buildPackage( packagePath ) {
const srcDir = path.resolve( packagePath, SRC_DIR );
let packageName;
try {
packageName = require( path.resolve( PACKAGE_DIR, 'package.json' ) ).name;
} catch ( e ) {
packageName = PACKAGE_DIR.split( path.sep ).pop();
}
process.stdout.write( chalk.inverse( `>> Building package: ${ packageName }\n` ) );
const jsFiles = glob.sync( `${ srcDir }/**/*.js`, {
ignore: [
`${ srcDir }/**/test/**/*.js`,
@ -144,8 +141,6 @@ function buildPackage( packagePath ) {
nodir: true,
} );
process.stdout.write( `${ path.basename( packagePath ) }\n` );
// Build js files individually.
jsFiles.forEach( ( file ) => buildJsFile( file, true ) );
@ -157,7 +152,5 @@ const files = process.argv.slice( 2 );
if ( files.length ) {
buildFiles( files );
} else {
process.stdout.write( chalk.inverse( '>> Building packages \n' ) );
getPackages().forEach( buildPackage );
process.stdout.write( '\n' );
buildPackage( PACKAGE_DIR );
}

View File

@ -1,83 +0,0 @@
/**
* External dependencies
*/
const fs = require( 'fs' );
const path = require( 'path' );
const { overEvery, compact, includes, negate } = require( 'lodash' );
/**
* Absolute path to packages directory.
*
* @type {string}
*/
const PACKAGES_DIR = path.resolve( __dirname, '../' );
const {
/**
* Comma-separated string of packages to include in build.
*
* @type {string}
*/
INCLUDE_PACKAGES,
/**
* Comma-separated string of packages to exclude from build.
*
* @type {string}
*/
EXCLUDE_PACKAGES,
} = process.env;
/**
* Given a comma-separated string, returns a filter function which returns true
* if the item is contained within as a comma-separated entry.
*
* @param {Function} filterFn Filter function to call with item to test.
* @param {string} list Comma-separated list of items.
*
* @return {Function} Filter function.
*/
const createCommaSeparatedFilter = ( filterFn, list ) => {
const listItems = list.split( ',' );
return ( item ) => filterFn( listItems, item );
};
/**
* Returns true if the given base file name for a file within the packages
* directory is itself a directory.
*
* @param {string} file Packages directory file.
*
* @return {boolean} Whether file is a directory.
*/
function isDirectory( file ) {
return fs.lstatSync( path.resolve( PACKAGES_DIR, file ) ).isDirectory();
}
/**
* Filter predicate, returning true if the given base file name is to be
* included in the build.
*
* @param {string} pkg File base name to test.
*
* @return {boolean} Whether to include file in build.
*/
const filterPackages = overEvery( compact( [
isDirectory,
INCLUDE_PACKAGES && createCommaSeparatedFilter( includes, INCLUDE_PACKAGES ),
EXCLUDE_PACKAGES && createCommaSeparatedFilter( negate( includes ), EXCLUDE_PACKAGES ),
] ) );
/**
* Returns the absolute path of all WordPress packages
*
* @return {Array} Package paths
*/
function getPackages() {
return fs
.readdirSync( PACKAGES_DIR )
.filter( filterPackages )
.map( ( file ) => path.resolve( PACKAGES_DIR, file ) );
}
module.exports = getPackages;

View File

@ -5,3 +5,6 @@ echo "Initializing WooCommerce E2E"
wp plugin install woocommerce --activate
wp theme install twentynineteen --activate
wp user create customer customer@woocommercecoree2etestsuite.com --user_pass=password --role=customer --path=/var/www/html
# we cannot create API keys for the API, so we using basic auth, this plugin allows that.
wp plugin install https://github.com/WP-API/Basic-Auth/archive/master.zip --activate

View File

@ -41,6 +41,10 @@
"access": "public"
},
"scripts": {
"clean": "rm -rf ./build ./build-module",
"compile": "node ./../bin/build.js",
"build": "npm run clean && npm run compile",
"prepare": "npm run build",
"docker:up": "./bin/docker-compose.js up",
"docker:down": "./bin/docker-compose.js down",
"docker:clear-all": "docker rmi --force $(docker images -q)",

View File

@ -0,0 +1,8 @@
/dist/
/node_modules
.eslintrc.js
.gitignore
jest.config.js
package.json
package-lock.json
tsconfig.json

View File

@ -0,0 +1,31 @@
module.exports = {
parser: '@typescript-eslint/parser',
env: {
'jest/globals': true
},
ignorePatterns: [
'dist/',
'node_modules/'
],
rules: {
'no-unused-vars': 'off',
'no-dupe-class-members': 'off',
},
extends: [
'plugin:@wordpress/eslint-plugin/recommended-with-formatting'
],
overrides: [
{
'files': [ '**/*.ts' ]
},
{
'files': [
'**/*.spec.ts',
'**/*.test.ts'
],
'rules': {
'no-console': 'off',
}
}
]
}

18
tests/e2e/factories/.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
# Editors
project.xml
project.properties
/nbproject/private/
.buildpath
.project
.settings*
.idea
.vscode
*.sublime-project
*.sublime-workspace
.sublimelinterrc
# Build Artifacts
/node_modules/
/dist/
tsconfig.tsbuildinfo

View File

@ -0,0 +1,58 @@
# Model Factories
A simple interface for generating models of different types.
## Installation
``bash
npm install @woocommerce/model-factories --save-dev
``
## Usage
Consumers of this package should rely on an instance of `ModelRegistry` to access the factories.
Here is an example of how to initialize and use the package to generate a simple product:
```javascript
import {
AdapterTypes,
initializeUsingBasicAuth,
ModelRegistry,
registerSimpleProduct,
SimpleProduct
} from '@woocommerce/model-factories';
// The ModelRegistry instance is where all of the factories and adapters are stored in an easy-to-access way.
const modelRegistry = new ModelRegistry()
// Call the register functions to add a kind of factory to the model registry.
// This will also add any adapters we've created for the factory, allowing it
// to be created on the server.
registerSimpleProduct( modelRegistry );
// Before you can use the included API adapter you need to initialize it using one of the utility methods.
// If you do not initialize the API adapters they will not be able to make requests to the API.
// Note that these utility functions only set up adapters that have been registered already
// and so further calls to `registeryXXX` functions will have adapters that aren't ready.
initializeUsingBasicAuth( modelRegistry, 'https://test.test/wp-json', 'admin', 'password' );
initializeUsingOAuth( modelRegistry, 'https://test.test/wp-json', 'consumer_key', 'consumer_secret' );
// In order to actually create the models on the server, each registered factory must have an adapter set.
// You can do this on a per-factory basis using
modelRegistry.changeFactoryAdapter( SimpleProduct, AdapterTypes.API );
// You can do this to all factories registered using
modelRegistry.changeAllFactoryAdapters( AdapterTypes.API );
// Once all of the initialization has been taken care of you can create models!
// Any fields that are not defined will be filled out by random data.
const product = await modelRegistry.getFactory( SimpleProduct ).create( { name: 'Test Product' } );
// You can now access the ID of the created model using `product.id`!
// You can also create models in bulk!
const poducts = await modelRegistry.getFactory( SimpleProduct ).createList( 5 );
// You now have an array of products to work with!
```
## Custom Models
## Custom Adapters

View File

@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testPathIgnorePatterns: [ '/node_modules/', '/dist/' ],
};

4985
tests/e2e/factories/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
{
"name": "@woocommerce/model-factories",
"version": "0.1.0",
"author": "Automattic",
"description": "A simple interface for generating models of different types.",
"homepage": "https://github.com/woocommerce/woocommerce/tree/master/tests/e2e/factories/README.md",
"repository": {
"type": "git",
"url": "https://github.com/woocommerce/woocommerce.git"
},
"keywords": [
"woocommerce",
"e2e"
],
"license": "GPL-3.0+",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"/dist/",
"!*.tsbuildinfo",
"!*.spec.js",
"!*.spec.d.ts",
"!*.test.js",
"!*.test.d.ts"
],
"sideEffects": false,
"scripts": {
"test": "jest",
"clean": "rm -rf ./dist ./tsconfig.tsbuildinfo",
"compile": "tsc -b",
"build": "npm run clean && npm run compile",
"prepare": "npm run build"
},
"dependencies": {
"axios": "0.19.2",
"create-hmac": "1.1.7",
"faker": "4.1.0",
"fishery": "1.0.0",
"oauth-1.0a": "2.2.6"
},
"devDependencies": {
"@types/create-hmac": "1.1.0",
"@types/faker": "4.1.12",
"@types/jest": "25.2.1",
"@types/moxios": "0.4.9",
"@types/node": "13.13.5",
"jest": "25.5.4",
"moxios": "0.4.0",
"ts-jest": "25.5.0",
"typescript": "3.8.3"
},
"publishConfig": {
"access": "public"
}
}

View File

@ -0,0 +1,16 @@
import { Model } from './model';
/**
* An interface for implementing adapters to create models.
*/
export interface Adapter<T extends Model> {
/**
* Creates a model or array of models using a service..
*
* @param {Model|Model[]} model The model or array of models to create.
* @return {Promise} Resolves to the created input model or array of models.
*/
create( model: T ): Promise<T>;
create( model: T[] ): Promise<T[]>;
create( model: T | T[] ): Promise<T> | Promise<T[]>;
}

View File

@ -0,0 +1,55 @@
import { Model } from '../model';
import { APIAdapter } from './api-adapter';
import { SimpleProduct } from '../../models/simple-product';
import { APIResponse, APIService } from './api-service';
class MockAPI implements APIService {
public get = jest.fn();
public post = jest.fn();
public put = jest.fn();
public patch = jest.fn();
public delete = jest.fn();
}
describe( 'APIModelCreator', () => {
let adapter: APIAdapter<Model>;
let mockService: MockAPI;
beforeEach( () => {
adapter = new APIAdapter( '/wc/v3/product', () => 'test' );
mockService = new MockAPI();
adapter.setAPIService( mockService );
} );
it( 'should create single instance', async () => {
mockService.post.mockReturnValueOnce( Promise.resolve( new APIResponse( 200, {}, { id: 1 } ) ) );
const result = await adapter.create( new SimpleProduct() );
expect( result ).toBeInstanceOf( SimpleProduct );
expect( result.id ).toBe( 1 );
expect( mockService.post.mock.calls[ 0 ][ 0 ] ).toBe( '/wc/v3/product' );
expect( mockService.post.mock.calls[ 0 ][ 1 ] ).toBe( 'test' );
} );
it( 'should create multiple instances', async () => {
mockService.post
.mockReturnValueOnce( Promise.resolve( new APIResponse( 200, {}, { id: 1 } ) ) )
.mockReturnValueOnce( Promise.resolve( new APIResponse( 200, {}, { id: 2 } ) ) )
.mockReturnValueOnce( Promise.resolve( new APIResponse( 200, {}, { id: 3 } ) ) );
const result = await adapter.create( [ new SimpleProduct(), new SimpleProduct(), new SimpleProduct() ] );
expect( result ).toBeInstanceOf( Array );
expect( result ).toHaveLength( 3 );
expect( result[ 0 ].id ).toBe( 1 );
expect( result[ 1 ].id ).toBe( 2 );
expect( result[ 2 ].id ).toBe( 3 );
expect( mockService.post.mock.calls[ 0 ][ 0 ] ).toBe( '/wc/v3/product' );
expect( mockService.post.mock.calls[ 0 ][ 1 ] ).toBe( 'test' );
expect( mockService.post.mock.calls[ 1 ][ 0 ] ).toBe( '/wc/v3/product' );
expect( mockService.post.mock.calls[ 1 ][ 1 ] ).toBe( 'test' );
expect( mockService.post.mock.calls[ 2 ][ 0 ] ).toBe( '/wc/v3/product' );
expect( mockService.post.mock.calls[ 2 ][ 1 ] ).toBe( 'test' );
} );
} );

View File

@ -0,0 +1,87 @@
import { APIResponse, APIService } from './api-service';
import { Model } from '../model';
import { Adapter } from '../adapter';
/**
* A callback for transforming models into an API request body.
*
* @callback APITransformerFn
* @param {Model} model The model that we want to transform.
* @return {*} The structured request data for the API.
*/
export type APITransformerFn<T extends Model> = ( model: T ) => any;
/**
* A class used for creating data models using a supplied API endpoint.
*/
export class APIAdapter<T extends Model> implements Adapter<T> {
private readonly endpoint: string;
private readonly transformer: APITransformerFn<T>;
private apiService: APIService | null;
public constructor( endpoint: string, transformer: APITransformerFn<T> ) {
this.endpoint = endpoint;
this.transformer = transformer;
this.apiService = null;
}
/**
* Sets the API service that the adapter should use for creation actions.
*
* @param {APIService|null} service The new API service for the adapter to use.
*/
public setAPIService( service: APIService | null ): void {
this.apiService = service;
}
/**
* Creates a model or array of models using the API service.
*
* @param {Model|Model[]} model The model or array of models to create.
* @return {Promise} Resolves to the created input model or array of models.
*/
public create( model: T ): Promise<T>;
public create( model: T[] ): Promise<T[]>;
public create( model: T | T[] ): Promise<T> | Promise<T[]> {
if ( ! this.apiService ) {
throw new Error( 'An API service must be registered for the adapter to work.' );
}
if ( Array.isArray( model ) ) {
return this.createList( model );
}
return this.createSingle( model );
}
/**
* Creates a single model using the API service.
*
* @param {Model} model The model to create.
* @return {Promise} Resolves to the created input model.
*/
private async createSingle( model: T ): Promise<T> {
return this.apiService!.post(
this.endpoint,
this.transformer( model ),
).then( ( data: APIResponse ) => {
model.setID( data.data.id );
return model;
} );
}
/**
* Creates an array of models using the API service.
*
* @param {Model[]} models The array of models to create.
* @return {Promise} Resolves to the array of created input models.
*/
private async createList( models: T[] ): Promise<T[]> {
const promises: Promise<T>[] = [];
for ( const model of models ) {
promises.push( this.createSingle( model ) );
}
return Promise.all( promises );
}
}

View File

@ -0,0 +1,100 @@
/**
* A structured response from the API.
*/
export class APIResponse<T = any> {
public readonly status: number;
public readonly headers: any;
public readonly data: T;
public constructor( status: number, headers: any, data: T ) {
this.status = status;
this.headers = headers;
this.data = data;
}
}
/**
* A structured error from the API.
*/
export class APIError {
public readonly code: string;
public readonly message: string;
public readonly data: any;
public constructor( code: string, message: string, data: any ) {
this.code = code;
this.message = message;
this.data = data;
}
}
/**
* Checks whether or not an APIResponse contains an error.
*
* @param {APIResponse} response The response to evaluate.
*/
export function isAPIError( response: APIResponse ): response is APIResponse<APIError> {
return response.status < 200 || response.status >= 400;
}
/**
* An interface for implementing services to make calls against the API.
*/
export interface APIService {
/**
* Performs a GET request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} params Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
get<T>(
endpoint: string,
params?: any
): Promise<APIResponse<T>>;
/**
* Performs a POST request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
post<T>(
endpoint: string,
data?: any
): Promise<APIResponse<T>>;
/**
* Performs a PUT request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
put<T>( endpoint: string, data?: any ): Promise<APIResponse<T>>;
/**
* Performs a PATCH request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
patch<T>(
endpoint: string,
data?: any
): Promise<APIResponse<T>>;
/**
* Performs a DELETE request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
delete<T>(
endpoint: string,
data?: any
): Promise<APIResponse<T>>;
}

View File

@ -0,0 +1,57 @@
import moxios from 'moxios';
import { APIResponse } from '../api-service';
import { AxiosAPIService } from './axios-api-service';
describe( 'AxiosAPIService', () => {
let apiClient: AxiosAPIService;
beforeEach( () => {
moxios.install();
} );
afterEach( () => {
moxios.uninstall();
} );
it( 'should add OAuth interceptors', async () => {
apiClient = AxiosAPIService.createUsingOAuth(
'http://test.test/wp-json/',
'consumer_key',
'consumer_secret',
);
moxios.stubOnce( 'GET', '/wc/v2/product', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
responseText: JSON.stringify( { test: 'value' } ),
} );
const response = await apiClient.get( '/wc/v2/product' );
expect( response ).toBeInstanceOf( APIResponse );
const request = moxios.requests.mostRecent();
expect( request.headers ).toHaveProperty( 'Authorization' );
expect( request.headers.Authorization ).toMatch( /^OAuth/ );
} );
it( 'should add basic auth interceptors', async () => {
apiClient = AxiosAPIService.createUsingBasicAuth( 'http://test.test/wp-json/', 'test', 'pass' );
moxios.stubOnce( 'GET', '/wc/v2/product', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
responseText: JSON.stringify( { test: 'value' } ),
} );
const response = await apiClient.get( '/wc/v2/product' );
expect( response ).toBeInstanceOf( APIResponse );
const request = moxios.requests.mostRecent();
expect( request.headers ).toHaveProperty( 'Authorization' );
expect( request.headers.Authorization ).toMatch( /^Basic/ );
} );
} );

View File

@ -0,0 +1,127 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { APIResponse, APIService } from '../api-service';
import { AxiosOAuthInterceptor } from './axios-oauth-interceptor';
import { AxiosInterceptor } from './axios-interceptor';
import { AxiosResponseInterceptor } from './axios-response-interceptor';
/**
* An API service implementation that uses Axios to make requests to the WordPress API.
*/
export class AxiosAPIService implements APIService {
private readonly client: AxiosInstance;
private readonly interceptors: AxiosInterceptor[];
public constructor( config: AxiosRequestConfig, interceptors: AxiosInterceptor[] = [] ) {
this.client = axios.create( config );
this.interceptors = interceptors;
for ( const interceptor of this.interceptors ) {
interceptor.start( this.client );
}
}
/**
* Creates a new Axios API Service using OAuth 1.0a one-legged authentication.
*
* @param {string} apiURL The base URL for the API requests to be sent.
* @param {string} consumerKey The OAuth consumer key.
* @param {string} consumerSecret The OAuth consumer secret.
* @return {AxiosAPIService} The created service.
*/
public static createUsingOAuth( apiURL: string, consumerKey: string, consumerSecret: string ): AxiosAPIService {
return new AxiosAPIService(
{ baseURL: apiURL },
[
new AxiosOAuthInterceptor( consumerKey, consumerSecret ),
new AxiosResponseInterceptor(),
],
);
}
/**
* Creates a new Axios API Service using basic authentication.
*
* @param {string} apiURL The base URL for the API requests to be sent.
* @param {string} username The username for authentication.
* @param {string} password The password for authentication.
* @return {AxiosAPIService} The created service.
*/
public static createUsingBasicAuth( apiURL: string, username: string, password: string ): AxiosAPIService {
return new AxiosAPIService(
{
baseURL: apiURL,
auth: { username, password },
},
[ new AxiosResponseInterceptor() ],
);
}
/**
* Performs a GET request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} params Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
public get<T>(
endpoint: string,
params?: any,
): Promise<APIResponse<T>> {
return this.client.get( endpoint, { params } );
}
/**
* Performs a POST request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
public post<T>(
endpoint: string,
data?: any,
): Promise<APIResponse<T>> {
return this.client.post( endpoint, data );
}
/**
* Performs a PUT request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
public put<T>(
endpoint: string,
data?: any,
): Promise<APIResponse<T>> {
return this.client.put( endpoint, data );
}
/**
* Performs a PATCH request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
public patch<T>(
endpoint: string,
data?: any,
): Promise<APIResponse<T>> {
return this.client.patch( endpoint, data );
}
/**
* Performs a DELETE request against the WordPress API.
*
* @param {string} endpoint The API endpoint we should query.
* @param {*} data Any parameters that should be passed in the request.
* @return {Promise} Resolves to an APIResponse and throws an APIResponse containing an APIError.
*/
public delete<T>(
endpoint: string,
data?: any,
): Promise<APIResponse<T>> {
return this.client.delete( endpoint, { data } );
}
}

View File

@ -0,0 +1,73 @@
import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
type ActiveInterceptor = {
client: AxiosInstance;
requestInterceptorID: number;
responseInterceptorID: number;
}
/**
* A base class for encapsulating the start and stop functionality required by all axios interceptors.
*/
export abstract class AxiosInterceptor {
private readonly activeInterceptors: ActiveInterceptor[] = [];
/**
* Starts intercepting requests and responses.
*
* @param {AxiosInstance} client The client to start intercepting the requests/responses of.
*/
public start( client: AxiosInstance ): void {
const requestInterceptorID = client.interceptors.request.use(
( response ) => this.handleRequest( response ),
);
const responseInterceptorID = client.interceptors.response.use(
( response ) => this.onResponseSuccess( response ),
( error ) => this.onResponseRejected( error ),
);
this.activeInterceptors.push( { client, requestInterceptorID, responseInterceptorID } );
}
/**
* Stops intercepting requests and responses.
*
* @param {AxiosInstance} client The client to stop intercepting the requests/responses of.
*/
public stop( client: AxiosInstance ): void {
for ( let i = this.activeInterceptors.length - 1; i >= 0; --i ) {
const active = this.activeInterceptors[ i ];
if ( client === active.client ) {
client.interceptors.request.eject( active.requestInterceptorID );
client.interceptors.response.eject( active.responseInterceptorID );
this.activeInterceptors.splice( i, 1 );
}
}
}
/**
* An interceptor method for handling requests before they are made to the server.
*
* @param {AxiosRequestConfig} config The axios request options.
*/
protected handleRequest( config: AxiosRequestConfig ): AxiosRequestConfig {
return config;
}
/**
* An interceptor method for handling successful responses.
*
* @param {AxiosResponse} response The response from the axios client.
*/
protected onResponseSuccess( response: AxiosResponse ): any {
return response;
}
/**
* An interceptor method for handling response failures.
*
* @param {*} error The error that occurred.
*/
protected onResponseRejected( error: any ): any {
return error;
}
}

View File

@ -0,0 +1,83 @@
import axios, { AxiosInstance } from 'axios';
import moxios from 'moxios';
import { AxiosOAuthInterceptor } from './axios-oauth-interceptor';
describe( 'AxiosOAuthInterceptor', () => {
let apiAuthInterceptor: AxiosOAuthInterceptor;
let axiosInstance: AxiosInstance;
beforeEach( () => {
axiosInstance = axios.create();
moxios.install( axiosInstance );
apiAuthInterceptor = new AxiosOAuthInterceptor(
'consumer_key',
'consumer_secret',
);
apiAuthInterceptor.start( axiosInstance );
} );
afterEach( () => {
apiAuthInterceptor.stop( axiosInstance );
moxios.uninstall( axiosInstance );
} );
it( 'should not run unless started', async () => {
moxios.stubOnce( 'GET', 'https://api.test', { status: 200 } );
apiAuthInterceptor.stop( axiosInstance );
await axiosInstance.get( 'https://api.test' );
let request = moxios.requests.mostRecent();
expect( request.headers ).not.toHaveProperty( 'Authorization' );
apiAuthInterceptor.start( axiosInstance );
await axiosInstance.get( 'https://api.test' );
request = moxios.requests.mostRecent();
expect( request.headers ).toHaveProperty( 'Authorization' );
} );
it( 'should use basic auth for HTTPS', async () => {
moxios.stubOnce( 'GET', 'https://api.test', { status: 200 } );
await axiosInstance.get( 'https://api.test' );
const request = moxios.requests.mostRecent();
expect( request.headers ).toHaveProperty( 'Authorization' );
expect( request.headers.Authorization ).toBe(
'Basic ' +
Buffer.from( 'consumer_key:consumer_secret' ).toString( 'base64' ),
);
} );
it( 'should use OAuth 1.0a for HTTP', async () => {
moxios.stubOnce( 'GET', 'http://api.test', { status: 200 } );
await axiosInstance.get( 'http://api.test' );
const request = moxios.requests.mostRecent();
// We're going to assume that the oauth-1.0a package added the signature data correctly so we will
// focus on ensuring that the header looks roughly correct given what we readily know.
expect( request.headers ).toHaveProperty( 'Authorization' );
expect( request.headers.Authorization ).toMatch(
/^OAuth oauth_consumer_key="consumer_key".*oauth_signature_method="HMAC-SHA256".*oauth_version="1.0"/,
);
} );
it( 'should work with base URL', async () => {
moxios.stubOnce( 'GET', '/test', { status: 200 } );
await axiosInstance.request( {
method: 'GET',
baseURL: 'https://api.test/',
url: '/test',
} );
const request = moxios.requests.mostRecent();
expect( request.headers ).toHaveProperty( 'Authorization' );
expect( request.headers.Authorization ).toBe(
'Basic ' +
Buffer.from( 'consumer_key:consumer_secret' ).toString( 'base64' ),
);
} );
} );

View File

@ -0,0 +1,51 @@
import { AxiosRequestConfig } from 'axios';
import createHmac from 'create-hmac';
import OAuth from 'oauth-1.0a';
import { AxiosInterceptor } from './axios-interceptor';
/**
* A utility class for managing the lifecycle of an authentication interceptor.
*/
export class AxiosOAuthInterceptor extends AxiosInterceptor {
private oauth: OAuth;
public constructor( consumerKey: string, consumerSecret: string ) {
super();
this.oauth = new OAuth( {
consumer: {
key: consumerKey,
secret: consumerSecret,
},
signature_method: 'HMAC-SHA256',
hash_function: ( base: any, key: any ) => {
return createHmac( 'sha256', key ).update( base ).digest( 'base64' );
},
} );
}
/**
* Adds WooCommerce API authentication details to the outgoing request.
*
* @param {AxiosRequestConfig} request The request that was intercepted.
* @return {AxiosRequestConfig} The request with the additional authorization headers.
*/
protected handleRequest( request: AxiosRequestConfig ): AxiosRequestConfig {
const url = ( request.baseURL || '' ) + ( request.url || '' );
if ( url.startsWith( 'https' ) ) {
request.auth = {
username: this.oauth.consumer.key,
password: this.oauth.consumer.secret,
};
} else {
request.headers.Authorization = this.oauth.toHeader(
this.oauth.authorize( {
url,
method: request.method!,
} ),
).Authorization;
}
return request;
}
}

View File

@ -0,0 +1,61 @@
import axios, { AxiosInstance } from 'axios';
import moxios from 'moxios';
import { APIResponse, APIError } from '../api-service';
import { AxiosResponseInterceptor } from './axios-response-interceptor';
describe( 'AxiosResponseInterceptor', () => {
let apiResponseInterceptor: AxiosResponseInterceptor;
let axiosInstance: AxiosInstance;
beforeEach( () => {
axiosInstance = axios.create();
moxios.install( axiosInstance );
apiResponseInterceptor = new AxiosResponseInterceptor();
apiResponseInterceptor.start( axiosInstance );
} );
afterEach( () => {
apiResponseInterceptor.stop( axiosInstance );
moxios.uninstall();
} );
it( 'should transform responses into APIResponse', async () => {
moxios.stubOnce( 'GET', 'http://test.test', {
status: 200,
headers: {
'Content-Type': 'application/json',
},
responseText: JSON.stringify( { test: 'value' } ),
} );
const response = await axiosInstance.get( 'http://test.test' );
expect( response ).toMatchObject( {
status: 200,
headers: {
'content-type': 'application/json',
},
data: {
test: 'value',
},
} );
} );
it( 'should transform response errors into APIError', async () => {
moxios.stubOnce( 'GET', 'http://test.test', {
status: 404,
headers: {
'Content-Type': 'application/json',
},
responseText: JSON.stringify( { code: 'error_code', message: 'value', data: null } ),
} );
await expect( axiosInstance.get( 'http://test.test' ) ).rejects.toMatchObject(
new APIResponse(
404,
{ 'content-type': 'application/json' },
new APIError( 'error_code', 'value', null ),
),
);
} );
} );

View File

@ -0,0 +1,39 @@
import { AxiosResponse } from 'axios';
import { APIResponse, APIError } from '../api-service';
import { AxiosInterceptor } from './axios-interceptor';
export class AxiosResponseInterceptor extends AxiosInterceptor {
/**
* Transforms the Axios response into our API response to be consumed in a consistent manner.
*
* @param {AxiosResponse} response The respons ethat we need to transform.
* @return {Promise} A promise containing the APIResponse.
*/
protected onResponseSuccess( response: AxiosResponse ): Promise<APIResponse> {
return Promise.resolve<APIResponse>(
new APIResponse( response.status, response.headers, response.data ),
);
}
/**
* Transforms HTTP errors into an API error if the error came from the API.
*
* @param {*} error The error that was caught.
*/
protected onResponseRejected( error: any ): Promise<APIResponse> {
// Only transform API errors.
if ( ! error.response ) {
throw error;
}
throw new APIResponse(
error.response.status,
error.response.headers,
new APIError(
error.response.data.code,
error.response.data.message,
error.response.data.data,
),
);
}
}

View File

@ -0,0 +1,14 @@
/**
* CORE CLASSES
* These exports relate to extending the core functionality of the package.
*/
export { Adapter } from './adapter';
export { ModelFactory } from './model-factory';
export { Model } from './model';
/**
* API ADAPTER
* These exports relate to replacing the underlying HTTP layer of API adapters.
*/
export { APIAdapter } from './api/api-adapter';
export { APIService, APIResponse, APIError } from './api/api-service';

View File

@ -0,0 +1,41 @@
import { ModelFactory } from './model-factory';
import { Adapter } from './adapter';
import { Product } from '../models/product';
import { SimpleProduct } from '../models/simple-product';
class MockAdapter implements Adapter<Product> {
public create = jest.fn();
}
describe( 'ModelFactory', () => {
let mockAdapter: MockAdapter;
let factory: ModelFactory<Product>;
beforeEach( () => {
mockAdapter = new MockAdapter();
factory = ModelFactory.define<Product, any, ModelFactory<Product>>(
( { params } ) => {
return new SimpleProduct( params );
},
);
} );
it( 'should error without adapter', async () => {
expect( () => factory.create() ).toThrowError( /no adapter/ );
} );
it( 'should create using adapter', async () => {
factory.setAdapter( mockAdapter );
const expectedModel = new SimpleProduct( { name: 'test2' } );
expectedModel.setID( 1 );
mockAdapter.create.mockReturnValueOnce( Promise.resolve( expectedModel ) );
const created = await factory.create( { name: 'test' } );
expect( mockAdapter.create.mock.calls ).toHaveLength( 1 );
expect( created ).toBeInstanceOf( Product );
expect( created.id ).toBe( 1 );
expect( created.name ).toBe( 'test2' );
} );
} );

View File

@ -0,0 +1,52 @@
import { DeepPartial, Factory, BuildOptions } from 'fishery';
import { Model } from './model';
import { Adapter } from './adapter';
/**
* A factory that can be used to create models using an adapter.
*/
export class ModelFactory<T extends Model, I = any> extends Factory<T, I> {
private adapter: Adapter<T> | null = null;
/**
* Sets the adapter that the factory will use to create models.
*
* @param {Adapter|null} adapter
*/
public setAdapter( adapter: Adapter<T> | null ): void {
this.adapter = adapter;
}
/**
* Create an object using your factory
*
* @param {DeepPartial} params The parameters that should populate the object.
* @param {BuildOptions} options The options to be used in the builder.
* @return {Promise} Resolves to the created model.
*/
public create( params?: DeepPartial<T>, options?: BuildOptions<T, I> ): Promise<T> {
if ( ! this.adapter ) {
throw new Error( 'The factory has no adapter to create using.' );
}
const model = this.build( params, options );
return this.adapter.create( model );
}
/**
* Create an array of objects using your factory
*
* @param {number} number The number of models to create.
* @param {DeepPartial} params The parameters that should populate the object.
* @param {BuildOptions} options The options to be used in the builder.
* @return {Promise} Resolves to the created model.
*/
public createList( number: number, params?: DeepPartial<T>, options?: BuildOptions<T, I> ): Promise<T[]> {
if ( ! this.adapter ) {
throw new Error( 'The factory has no adapter to create using.' );
}
const model = this.buildList( number, params, options );
return this.adapter.create( model );
}
}

View File

@ -0,0 +1,45 @@
import { AdapterTypes, ModelRegistry } from './model-registry';
import { ModelFactory } from './model-factory';
import { Product } from '../models/product';
import { APIAdapter } from './api/api-adapter';
import { SimpleProduct } from '../models/simple-product';
describe( 'ModelRegistry', () => {
let factoryRegistry: ModelRegistry;
beforeEach( () => {
factoryRegistry = new ModelRegistry();
} );
it( 'should register factories once', () => {
const factory = ModelFactory.define<Product, any, ModelFactory<Product>>( ( { params } ) => {
return new SimpleProduct( params );
} );
expect( factoryRegistry.getFactory( SimpleProduct ) ).toBeNull();
factoryRegistry.registerFactory( SimpleProduct, factory );
expect( () => factoryRegistry.registerFactory( SimpleProduct, factory ) )
.toThrowError( /already been registered/ );
const loaded = factoryRegistry.getFactory( SimpleProduct );
expect( loaded ).toBe( factory );
} );
it( 'should register adapters once', () => {
const adapter = new APIAdapter<Product>( '', ( model ) => model );
expect( factoryRegistry.getAdapter( SimpleProduct, AdapterTypes.API ) ).toBeNull();
factoryRegistry.registerAdapter( SimpleProduct, AdapterTypes.API, adapter );
expect( () => factoryRegistry.registerAdapter( SimpleProduct, AdapterTypes.API, adapter ) )
.toThrowError( /already been registered/ );
const loaded = factoryRegistry.getAdapter( SimpleProduct, AdapterTypes.API );
expect( loaded ).toBe( adapter );
} );
} );

View File

@ -0,0 +1,125 @@
import { Adapter } from './adapter';
import { Model } from './model';
import { ModelFactory } from './model-factory';
type Registry<T> = { [key: string ]: T };
/**
* The types of adapters that can be stored in the registry.
*
* @typedef AdapterTypes
* @property {string} API "api"
* @property {string} Custom "custom"
*/
export enum AdapterTypes {
API = 'api',
Custom = 'custom'
}
/**
* A registry that allows for us to easily manage all of our factories and related state.
*/
export class ModelRegistry {
private readonly factories: Registry<ModelFactory<any>> = {};
private readonly adapters: { [key in AdapterTypes]: Registry<Adapter<any>> } = {
api: {},
custom: {},
};
/**
* Registers a factory for the class.
*
* @param {Function} modelClass The class of model we're registering the factory for.
* @param {ModelFactory} factory The factory that we're registering.
*/
public registerFactory<T extends Model>( modelClass: new () => T, factory: ModelFactory<T> ): void {
if ( this.factories.hasOwnProperty( modelClass.name ) ) {
throw new Error( 'A factory of this type has already been registered for the model class.' );
}
this.factories[ modelClass.name ] = factory;
}
/**
* Fetches a factory that was registered for the class.
*
* @param {Function} modelClass The class of model for the factory we're fetching.
*/
public getFactory<T extends Model>( modelClass: new () => T ): ModelFactory<T> | null {
if ( this.factories.hasOwnProperty( modelClass.name ) ) {
return this.factories[ modelClass.name ];
}
return null;
}
/**
* Registers an adapter for the class.
*
* @param {Function} modelClass The class of model that we're registering the adapter for.
* @param {AdapterTypes} type The type of adapter that we're registering.
* @param {Adapter} adapter The adapter that we're registering.
*/
public registerAdapter<T extends Model>( modelClass: new () => T, type: AdapterTypes, adapter: Adapter<T> ): void {
if ( this.adapters[ type ].hasOwnProperty( modelClass.name ) ) {
throw new Error( 'An adapter of this type has already been registered for the model class.' );
}
this.adapters[ type ][ modelClass.name ] = adapter;
}
/**
* Fetches an adapter registered for the class.
*
* @param {Function} modelClass The class of the model for the adapter we're fetching.
* @param {AdapterTypes} type The type of adapter we're fetching.
*/
public getAdapter<T extends Model>( modelClass: new () => T, type: AdapterTypes ): Adapter<T> | null {
if ( this.adapters[ type ].hasOwnProperty( modelClass.name ) ) {
return this.adapters[ type ][ modelClass.name ];
}
return null;
}
/**
* Fetches all of the adapters of a given type from the registry.
*
* @param {AdapterTypes} type The type of adapters to fetch.
*/
public getAdapters( type: AdapterTypes ): Adapter<any>[] {
return Object.values( this.adapters[ type ] );
}
/**
* Changes the adapter a factory is using.
*
* @param {Function} modelClass The class of the model factory we're changing.
* @param {AdapterTypes} type The type of adapter to set.
*/
public changeFactoryAdapter<T extends Model>( modelClass: new () => T, type: AdapterTypes ): void {
const factory = this.getFactory( modelClass );
if ( ! factory ) {
throw new Error( 'No factory defined for this model class.' );
}
const adapter = this.getAdapter( modelClass, type );
if ( ! adapter ) {
throw new Error( 'No adapter of this type registered for this model class.' );
}
factory.setAdapter( adapter );
}
/**
* Changes the adapters of all factories to the given type or null if one is not registered for that type.
*
* @param {AdapterTypes} type The type of adapter to set.
*/
public changeAllFactoryAdapters( type: AdapterTypes ): void {
for ( const key in this.factories ) {
this.factories[ key ].setAdapter(
this.adapters[ type ][ key ] || null,
);
}
}
}

View File

@ -0,0 +1,20 @@
import { DeepPartial } from 'fishery';
/**
* A base class for all models.
*/
export abstract class Model {
private _id: number = 0;
protected constructor( partial: DeepPartial<any> = {} ) {
Object.assign( this, partial );
}
public get id(): number {
return this._id;
}
public setID( id: number ): void {
this._id = id;
}
}

View File

@ -0,0 +1,17 @@
/**
* FRAMEWORK CLASSES
* These exports relate to the core classes needed to utilize the package.
*/
export { ModelRegistry, AdapterTypes } from './framework/model-registry';
/**
* MODELS
* This exports all of the models we have defined and their related functions.
*/
export * from './models';
/**
* UTILITIES
* These exports relate to common utilities that can be used to utilize the package.
*/
export { initializeUsingOAuth, initializeUsingBasicAuth } from './utils';

View File

@ -0,0 +1,2 @@
export { Product } from './product';
export { SimpleProduct, registerSimpleProduct } from './simple-product';

View File

@ -0,0 +1,15 @@
import { Model } from '../framework/model';
import { DeepPartial } from 'fishery';
/**
* The base class for all product types.
*/
export abstract class Product extends Model {
public readonly name: string = '';
public readonly regularPrice: string = '';
protected constructor( partial: DeepPartial<Product> = {} ) {
super( partial );
Object.assign( this, partial );
}
}

View File

@ -0,0 +1,48 @@
import { DeepPartial } from 'fishery';
import { Product } from './product';
import { AdapterTypes, ModelRegistry } from '../framework/model-registry';
import { ModelFactory } from '../framework/model-factory';
import { APIAdapter } from '../framework/api/api-adapter';
import faker from 'faker/locale/en';
export class SimpleProduct extends Product {
public constructor( partial: DeepPartial<SimpleProduct> = {} ) {
super( partial );
Object.assign( this, partial );
}
}
/**
* Registers the simple product factory and adapters.
*
* @param {ModelRegistry} registry The registry to hold the model reference.
*/
export function registerSimpleProduct( registry: ModelRegistry ): void {
if ( null !== registry.getFactory( SimpleProduct ) ) {
return;
}
const factory = ModelFactory.define<SimpleProduct, any, ModelFactory<SimpleProduct>>(
( { params } ) => {
return new SimpleProduct(
{
name: params.name ?? faker.commerce.productName(),
regularPrice: params.regularPrice ?? faker.commerce.price(),
},
);
},
);
registry.registerFactory( SimpleProduct, factory );
const apiAdapter = new APIAdapter<SimpleProduct>(
'/wc/v3/products',
( model ) => {
return {
type: 'simple',
name: model.name,
regular_price: model.regularPrice,
};
},
);
registry.registerAdapter( SimpleProduct, AdapterTypes.API, apiAdapter );
}

View File

@ -0,0 +1,55 @@
import { AdapterTypes, ModelRegistry } from './framework/model-registry';
import { APIAdapter } from './framework/api/api-adapter';
import { AxiosAPIService } from './framework/api/axios/axios-api-service';
/**
* Initializes all of the APIAdapters with a client to communicate with the API.
*
* @param {ModelRegistry} registry The model registry that we want to initialize.
* @param {string} apiURL The base URL for the API.
* @param {string} consumerKey The OAuth consumer key for the API service.
* @param {string} consumerSecret The OAuth consumer secret for the API service.
*/
export function initializeUsingOAuth(
registry: ModelRegistry,
apiURL: string,
consumerKey: string,
consumerSecret: string,
): void {
const adapters = registry.getAdapters( AdapterTypes.API ) as APIAdapter<any>[];
if ( ! adapters.length ) {
return;
}
const apiService = AxiosAPIService.createUsingOAuth( apiURL, consumerKey, consumerSecret );
for ( const adapter of adapters ) {
adapter.setAPIService( apiService );
}
}
/**
* Initialize all of the APIAdapters with a client to communicate with the API.
*
*
*
* @param {ModelRegistry} registry The model registry that we want to initialize.
* @param {string} apiURL The base URL for the API.
* @param {string} username The username to use for authentication.
* @param {string} password The password to use for authentication.
*/
export function initializeUsingBasicAuth(
registry: ModelRegistry,
apiURL: string,
username: string,
password: string,
): void {
const adapters = registry.getAdapters( AdapterTypes.API ) as APIAdapter<any>[];
if ( ! adapters.length ) {
return;
}
const apiService = AxiosAPIService.createUsingBasicAuth( apiURL, username, password );
for ( const adapter of adapters ) {
adapter.setAPIService( apiService );
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"types": [ "node", "jest", "faker", "axios", "moxios", "create-hmac" ],
"rootDir": "src",
"outDir": "dist"
},
"include": [ "src/" ]
}

View File

@ -7,6 +7,8 @@
*/
import { StoreOwnerFlow } from './flows';
import { clickTab, uiUnblocked, verifyCheckboxIsUnset } from './index';
import modelRegistry from './factories';
import { SimpleProduct } from '@woocommerce/model-factories';
const config = require( 'config' );
const simpleProductName = config.get( 'products.simple.name' );
@ -116,7 +118,7 @@ const completeOnboardingWizard = async () => {
expect( productTypesCheckboxes ).toHaveLength( 8 );
// Select Physical and Downloadable products
for ( let i = 0; i < 2; i++ ) {
for ( let i = 1; i < 2; i++ ) {
await productTypesCheckboxes[i].click();
}
@ -342,22 +344,11 @@ const completeOldSetupWizard = async () => {
* Create simple product.
*/
const createSimpleProduct = async () => {
// Go to "add product" page
await StoreOwnerFlow.openNewProduct();
// Make sure we're on the add order page
await expect( page.title() ).resolves.toMatch( 'Add new product' );
// Set product data
await expect( page ).toFill( '#title', simpleProductName );
await clickTab( 'General' );
await expect( page ).toFill( '#_regular_price', '9.99' );
await verifyAndPublish();
const simplePostId = await page.$( '#post_ID' );
let simplePostIdValue = ( await ( await simplePostId.getProperty( 'value' ) ).jsonValue() );
return simplePostIdValue;
const product = await modelRegistry.getFactory( SimpleProduct ).create( {
name: simpleProductName,
regularPrice: '9.99'
} );
return product.id;
} ;
/**

View File

@ -0,0 +1,24 @@
import {
AdapterTypes,
initializeUsingBasicAuth,
ModelRegistry,
registerSimpleProduct
} from '@woocommerce/model-factories';
const config = require( 'config' );
const modelRegistry = new ModelRegistry()
// Register all of the different factories that we're going to need.
registerSimpleProduct( modelRegistry );
// Make sure to perform the initialization AFTER registering all of the factories, otherwise the adapters might be
// missed on subsequent registrations.
initializeUsingBasicAuth( modelRegistry,
config.get( 'url' ) + '/wp-json',
config.get( 'users.admin.username' ),
config.get( 'users.admin.password' )
);
modelRegistry.changeAllFactoryAdapters( AdapterTypes.API );
export default modelRegistry;

View File

@ -12,7 +12,7 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
$variations = $product->get_available_variations();
$this->assertIsArray( $variations[0] );
$this->assertTrue( is_array( $variations[0] ) );
$this->assertEquals( 'DUMMY SKU VARIABLE SMALL', $variations[0]['sku'] );
}
@ -24,7 +24,7 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
$variations = $product->get_available_variations( 'array' );
$this->assertIsArray( $variations[0] );
$this->assertTrue( is_array( $variations[0] ) );
$this->assertEquals( 'DUMMY SKU VARIABLE SMALL', $variations[0]['sku'] );
}

20
tsconfig.base.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"incremental": true,
"allowJs": true,
"checkJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"declaration": true,
"composite": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}

6
tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"references": [
{ "path": "tests/e2e/factories" }
],
"files": []
}