Separated the construction of factories & repositories from the models for simplicity

This commit is contained in:
Christopher Allford 2020-09-18 13:53:10 -07:00
parent a9ee9806a4
commit b3b92e2d4d
8 changed files with 212 additions and 174 deletions

View File

@ -0,0 +1,21 @@
import { AsyncFactory } from '../../framework/async-factory';
import { SimpleProduct } from '../../models';
import Mock = jest.Mock;
import { simpleProductFactory } from '../simple-product';
describe( 'simpleProductFactory', () => {
let mockCreator: Mock;
let factory: AsyncFactory< SimpleProduct >;
beforeEach( () => {
mockCreator = jest.fn();
factory = simpleProductFactory( mockCreator );
} );
it( 'should build', () => {
const model = factory.build( { name: 'Test Product' } );
expect( model ).toMatchObject( { name: 'Test Product' } );
expect( parseFloat( model.regularPrice ) ).toBeGreaterThan( 0.0 );
} );
} );

View File

@ -0,0 +1,22 @@
import { AsyncFactory } from '../framework/async-factory';
import { commerce } from 'faker/locale/en';
import { SimpleProduct } from '../models';
import { CreateFn } from '../framework/model-repository';
/**
* Creates a new factory for creating models.
*
* @param {Function} creator The function we will use for creating models.
* @return {AsyncFactory} The factory for creating models.
*/
export function simpleProductFactory( creator: CreateFn< SimpleProduct > ): AsyncFactory< SimpleProduct > {
return new AsyncFactory< SimpleProduct >(
( { params } ) => {
return new SimpleProduct( {
name: params.name ?? commerce.productName(),
regularPrice: params.regularPrice ?? commerce.price(),
} );
},
creator,
);
}

View File

@ -1,48 +1,76 @@
import { Model } from '../../models/model'; import { Model } from '../../models/model';
import { ModelRepository } from '../model-repository'; import { ModelRepository } from '../model-repository';
import Mock = jest.Mock;
class DummyModel extends Model { class DummyModel extends Model {
public name: string = ''; public name: string = '';
public constructor( partial?: Partial< DummyModel > ) {
super();
Object.assign( this, partial );
}
} }
describe( 'ModelRepository', () => { describe( 'ModelRepository', () => {
let dummyModel: DummyModel; it( 'should create', async () => {
let mockCallback: Mock; const model = new DummyModel();
let repository: ModelRepository< DummyModel >; const callback = jest.fn().mockResolvedValue( model );
const repository = new ModelRepository< DummyModel >( callback, null, null, null );
beforeEach( () => { const created = await repository.create( { name: 'test' } );
dummyModel = new DummyModel(); expect( created ).toBe( model );
dummyModel.name = 'test'; expect( callback ).toHaveBeenCalledWith( { name: 'test' } );
mockCallback = jest.fn();
repository = new ModelRepository< DummyModel >( mockCallback, mockCallback, mockCallback, mockCallback );
} ); } );
it( 'should create', async () => { it( 'should throw error on create without callback', () => {
mockCallback.mockResolvedValue( dummyModel ); const repository = new ModelRepository< DummyModel >( null, null, null, null );
await repository.create( dummyModel ); expect( () => repository.create( { name: 'test' } ) ).toThrowError( /not defined/i );
expect( mockCallback ).toHaveBeenCalledWith( dummyModel );
} ); } );
it( 'should read', async () => { it( 'should read', async () => {
mockCallback.mockResolvedValue( dummyModel ); const model = new DummyModel();
const callback = jest.fn().mockResolvedValue( model );
const repository = new ModelRepository< DummyModel >( null, callback, null, null );
await repository.read( { id: 1 } ); const created = await repository.read( 1 );
expect( mockCallback ).toHaveBeenCalledWith( { id: 1 } ); expect( created ).toBe( model );
expect( callback ).toHaveBeenCalledWith( 1 );
} );
it( 'should throw error on read without callback', () => {
const repository = new ModelRepository< DummyModel >( null, null, null, null );
expect( () => repository.read( 1 ) ).toThrowError( /not defined/i );
} ); } );
it( 'should update', async () => { it( 'should update', async () => {
mockCallback.mockResolvedValue( dummyModel ); const model = new DummyModel();
const callback = jest.fn().mockResolvedValue( model );
const repository = new ModelRepository< DummyModel >( null, null, callback, null );
await repository.update( dummyModel ); const updated = await repository.update( 1, { name: 'new-name' } );
expect( mockCallback ).toHaveBeenCalledWith( dummyModel ); expect( updated ).toBe( model );
expect( callback ).toHaveBeenCalledWith( 1, { name: 'new-name' } );
} );
it( 'should throw error on update without callback', () => {
const repository = new ModelRepository< DummyModel >( null, null, null, null );
expect( () => repository.update( 1, { name: 'new-name' } ) ).toThrowError( /not defined/i );
} ); } );
it( 'should delete', async () => { it( 'should delete', async () => {
mockCallback.mockResolvedValue( true ); const callback = jest.fn().mockResolvedValue( true );
const repository = new ModelRepository< DummyModel >( null, null, null, callback );
await repository.delete( dummyModel ); const success = await repository.delete( 1 );
expect( mockCallback ).toHaveBeenCalledWith( dummyModel ); expect( success ).toBe( true );
expect( callback ).toHaveBeenCalledWith( 1 );
} );
it( 'should throw error on delete without callback', () => {
const repository = new ModelRepository< DummyModel >( null, null, null, null );
expect( () => repository.delete( 1 ) ).toThrowError( /not defined/i );
} ); } );
} ); } );

View File

@ -1,39 +1,32 @@
import { Model } from '../models/model'; import { Model } from '../models/model';
type CreateFn< T > = ( model: T ) => Promise< T >; export type CreateFn< T > = ( properties: Partial< T > ) => Promise< T >;
type ReadFn< T, P > = ( params: P ) => Promise< T >; export type ReadFn< IDParam, T > = ( id: IDParam ) => Promise< T >;
type UpdateFn< T > = ( model: T ) => Promise< T >; export type UpdateFn< IDParam, T > = ( id: IDParam, properties: Partial< T > ) => Promise< T >;
type DeleteFn< T > = ( model: T ) => Promise< boolean >; export type DeleteFn< IDParam > = ( id: IDParam ) => Promise< boolean >;
/**
* The standard parameters for reading a model.
*/
interface DefaultReadParams {
id: number;
}
/** /**
* An interface for repositories that perform CRUD actions. * An interface for repositories that perform CRUD actions.
*/ */
export class ModelRepository< T extends Model, ReadParams = DefaultReadParams > { export class ModelRepository< T extends Model, IDParam = number > {
private readonly createHook: CreateFn< T >; private readonly createHook: CreateFn< T > | null;
private readonly readHook: ReadFn< T, ReadParams >; private readonly readHook: ReadFn< IDParam, T > | null;
private readonly updateHook: UpdateFn< T >; private readonly updateHook: UpdateFn< IDParam, T > | null;
private readonly deleteHook: DeleteFn< T >; private readonly deleteHook: DeleteFn< IDParam > | null;
/** /**
* Creates a new repository instance. * Creates a new repository instance.
* *
* @param {Function} createHook The hook for model creation. * @param {Function|null} createHook The hook for model creation.
* @param {Function} readHook The hook for model reading. * @param {Function|null} readHook The hook for model reading.
* @param {Function} updateHook The hook for model updating. * @param {Function|null} updateHook The hook for model updating.
* @param {Function} deleteHook The hook for model deletion. * @param {Function|null} deleteHook The hook for model deletion.
*/ */
public constructor( public constructor(
createHook: CreateFn< T >, createHook: CreateFn< T > | null,
readHook: ReadFn< T, ReadParams >, readHook: ReadFn< IDParam, T > | null,
updateHook: UpdateFn< T >, updateHook: UpdateFn< IDParam, T > | null,
deleteHook: DeleteFn< T >, deleteHook: DeleteFn< IDParam > | null,
) { ) {
this.createHook = createHook; this.createHook = createHook;
this.readHook = readHook; this.readHook = readHook;
@ -44,40 +37,57 @@ export class ModelRepository< T extends Model, ReadParams = DefaultReadParams >
/** /**
* Creates the given model. * Creates the given model.
* *
* @param {*} model The model that we would like to create. * @param {*} properties The properties for the model we'd like to create.
* @return {Promise} A promise that resolves to the model after creation. * @return {Promise} A promise that resolves to the model after creation.
*/ */
public create( model: T ): Promise< T > { public create( properties: Partial< T > ): Promise< T > {
return this.createHook( model ); if ( ! this.createHook ) {
throw new Error( 'The \'create\' hook is not defined.' );
}
return this.createHook( properties );
} }
/** /**
* Reads the given model. * Reads the given model.
* *
* @param {Object} params The parameters to help with reading the model. * @param {Object} id The identifier for the model to read.
* @return {Promise} A promise that resolves to the model. * @return {Promise} A promise that resolves to the model.
*/ */
public read( params: ReadParams ): Promise< T > { public read( id: IDParam ): Promise< T > {
return this.readHook( params ); if ( ! this.readHook ) {
throw new Error( 'The \'read\' hook is not defined.' );
}
return this.readHook( id );
} }
/** /**
* Updates the given model. * Updates the given model.
* *
* @param {*} model The model we want to update. * @param {*} id The identifier for the model to create.
* @param {*} properties The model properties that we'd like to update.
* @return {Promise} A promise that resolves to the model after updating. * @return {Promise} A promise that resolves to the model after updating.
*/ */
public update( model: T ): Promise< T > { public update( id: IDParam, properties: Partial< T > ): Promise< T > {
return this.updateHook( model ); if ( ! this.updateHook ) {
throw new Error( 'The \'update\' hook is not defined.' );
}
return this.updateHook( id, properties );
} }
/** /**
* Deletes the given model. * Deletes the given model.
* *
* @param {*} model The model we want to delete. * @param {*} id The identifier for the model to delete.
* @return {Promise} A promise that resolves to "true" on success. * @return {Promise} A promise that resolves to "true" on success.
*/ */
public delete( model: T ): Promise< boolean > { public delete( id: IDParam ): Promise< boolean > {
return this.deleteHook( model ); if ( ! this.deleteHook ) {
throw new Error( 'The \'delete\' hook is not defined.' );
}
return this.deleteHook( id );
} }
} }

View File

@ -1,68 +0,0 @@
import { SimpleProduct } from '../simple-product';
import { mock, MockProxy } from 'jest-mock-extended';
import { HTTPClient, HTTPResponse } from '../../../http';
import { ModelRepository } from '../../../framework/model-repository';
describe( 'SimpleProduct', () => {
describe( 'restRepository', () => {
let httpClient: MockProxy< HTTPClient >;
let repository: ModelRepository< SimpleProduct >;
beforeEach( () => {
httpClient = mock< HTTPClient >();
repository = SimpleProduct.restRepository( httpClient );
} );
it( 'should create', async () => {
httpClient.post.mockResolvedValue( new HTTPResponse( 200, {}, { id: 2 } ) );
const created = await repository.create( new SimpleProduct( { name: 'test' } ) );
expect( created ).toHaveProperty( 'id', 2 );
expect( httpClient.post ).toHaveBeenCalledWith(
'/wc/v3/products',
{
name: 'test',
},
);
} );
it( 'should read', async () => {
httpClient.get.mockResolvedValue(
new HTTPResponse( 200, {}, {
id: 12,
name: 'test-name',
} ),
);
const read = await repository.read( { id: 12 } );
expect( read ).toHaveProperty( 'id', 12 );
expect( httpClient.get ).toHaveBeenCalledWith( '/wc/v3/products/12' );
} );
it( 'should update', async () => {
httpClient.put.mockResolvedValue( new HTTPResponse( 200, {}, { id: 1 } ) );
const updated = await repository.update( new SimpleProduct( { id: 1, name: 'test' } ) );
expect( updated ).toHaveProperty( 'id', 1 );
expect( httpClient.put ).toHaveBeenCalledWith(
'/wc/v3/products/1',
{
id: 1,
name: 'test',
},
);
} );
it( 'should delete', async () => {
httpClient.delete.mockResolvedValue( new HTTPResponse( 200, {}, {} ) );
const response = await repository.delete( new SimpleProduct( { id: 123 } ) );
expect( response ).toBeTruthy();
expect( httpClient.delete ).toHaveBeenCalledWith( '/wc/v3/products/123' );
} );
} );
} );

View File

@ -1,8 +1,9 @@
import { AbstractProduct } from './abstract-product'; import { AbstractProduct } from './abstract-product';
import * as faker from 'faker/locale/en';
import { HTTPClient } from '../../http'; import { HTTPClient } from '../../http';
import { ModelRepository } from '../../framework/model-repository'; import { ModelRepository } from '../../framework/model-repository';
import { AsyncFactory } from '../../framework/async-factory'; import { AsyncFactory } from '../../framework/async-factory';
import { simpleProductFactory } from '../../factories/simple-product';
import { simpleProductRESTRepository } from '../../repositories/rest/simple-product';
/** /**
* The simple product class. * The simple product class.
@ -20,44 +21,7 @@ export class SimpleProduct extends AbstractProduct {
* @return {ModelRepository} The created repository. * @return {ModelRepository} The created repository.
*/ */
public static restRepository( httpClient: HTTPClient ): ModelRepository< SimpleProduct > { public static restRepository( httpClient: HTTPClient ): ModelRepository< SimpleProduct > {
return new ModelRepository( return simpleProductRESTRepository( httpClient );
async ( model ) => {
const response = await httpClient.post(
'/wc/v3/products',
{
name: model.name,
},
);
return Promise.resolve( new SimpleProduct( {
id: response.data.id,
name: response.data.name,
} ) );
},
async ( params ) => {
const response = await httpClient.get( '/wc/v3/products/' + params.id );
const model = new SimpleProduct(
{
id: response.data.id,
name: response.data.name,
},
);
return Promise.resolve( model );
},
async ( model ) => {
return httpClient.put(
'/wc/v3/products/' + model.id,
{
id: model.id,
name: model.name,
},
).then( () => model );
},
async ( model ) => {
return httpClient.delete( '/wc/v3/products/' + model.id ).then( () => true );
},
);
} }
/** /**
@ -67,14 +31,6 @@ export class SimpleProduct extends AbstractProduct {
* @return {AsyncFactory} The new factory instance. * @return {AsyncFactory} The new factory instance.
*/ */
public static factory( repository: ModelRepository< SimpleProduct > ): AsyncFactory< SimpleProduct > { public static factory( repository: ModelRepository< SimpleProduct > ): AsyncFactory< SimpleProduct > {
return new AsyncFactory< SimpleProduct >( return simpleProductFactory( repository.create );
( { params } ) => {
return new SimpleProduct( {
name: params.name ?? faker.commerce.productName(),
regularPrice: params.regularPrice ?? faker.commerce.price(),
} );
},
repository.create,
);
} }
} }

View File

@ -0,0 +1,29 @@
import { simpleProductRESTRepository } from '../simple-product';
import { mock, MockProxy } from 'jest-mock-extended';
import { HTTPClient, HTTPResponse } from '../../../http';
import { SimpleProduct } from '../../../models';
import { ModelRepository } from '../../../framework/model-repository';
describe( 'simpleProductRESTRepository', () => {
let httpClient: MockProxy< HTTPClient >;
let repository: ModelRepository< SimpleProduct >;
beforeEach( () => {
httpClient = mock< HTTPClient >();
repository = simpleProductRESTRepository( httpClient );
} );
it( 'should create', async () => {
httpClient.post.mockResolvedValue( new HTTPResponse(
200,
{},
{ id: 123 },
) );
const created = await repository.create( { name: 'Test Product' } );
expect( created ).toBeInstanceOf( SimpleProduct );
expect( created ).toMatchObject( { id: 123 } );
expect( httpClient.post ).toHaveBeenCalledWith( '/wc/v3/products', { name: 'Test Product' } );
} );
} );

View File

@ -0,0 +1,40 @@
import { HTTPClient } from '../../http';
import { CreateFn, ModelRepository } from '../../framework/model-repository';
import { SimpleProduct } from '../../models';
/**
* Creates a callback for REST model creation.
*
* @param {HTTPClient} httpClient The HTTP client for requests.
* @return {Function} The callback for creating models via the REST API.
*/
function restCreate( httpClient: HTTPClient ): CreateFn< SimpleProduct > {
return async ( properties ) => {
const response = await httpClient.post(
'/wc/v3/products',
{
name: properties.name,
},
);
return Promise.resolve( new SimpleProduct( {
id: response.data.id,
name: response.data.name,
} ) );
};
}
/**
* 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 {ModelRepository} A repository for interacting with models via the REST API.
*/
export function simpleProductRESTRepository( httpClient: HTTPClient ): ModelRepository< SimpleProduct > {
return new ModelRepository(
restCreate( httpClient ),
null,
null,
null,
);
}