diff --git a/.gitignore b/.gitignore index b651894..a885083 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ typings/ !/.config-sample/config.js .idea/workspace.xml .idea/dictionaries/ -npm-debug.log \ No newline at end of file +npm-debug.log +coverage/ \ No newline at end of file diff --git a/.idea/runConfigurations/Mocha.xml b/.idea/runConfigurations/Mocha.xml new file mode 100644 index 0000000..7d1d43e --- /dev/null +++ b/.idea/runConfigurations/Mocha.xml @@ -0,0 +1,16 @@ + + + project + + $PROJECT_DIR$ + true + + + + bdd + -r reflect-metadata -r ./mochaInit + PATTERN + ./{,!(node_modules)/**/}/*.spec.js + + + \ No newline at end of file diff --git a/.idea/ts-orm.iml b/.idea/ts-orm.iml index 6106421..684f57e 100644 --- a/.idea/ts-orm.iml +++ b/.idea/ts-orm.iml @@ -1,7 +1,11 @@ - + + + + + diff --git a/.istanbul.yml b/.istanbul.yml new file mode 100644 index 0000000..839a515 --- /dev/null +++ b/.istanbul.yml @@ -0,0 +1,23 @@ +instrumentation: + excludes: ['*.spec.js', '*.spec.class.js'] + include-all-sources: true +reporting: + watermarks: + statements: [80, 100] + lines: [80, 100] + functions: [80, 100] + branches: [80, 100] +check: + each: + statements: -3 + lines: -1 + branches: -13 + functions: -1 + # Theres a bug in istanbuhl around the check-coverage command and sourcemaps, + # replace the above with this when it's fixed + #global: + #statements: 100 + #lines: 100 + #branches: 100 + #functions: 100 + diff --git a/.npmignore b/.npmignore index 3369955..bf8294f 100644 --- a/.npmignore +++ b/.npmignore @@ -3,4 +3,14 @@ typings/ *.ts !*.d.ts .idea/ -npm-debug.log \ No newline at end of file +npm-debug.log +coverage/ +mocks/ +*.spec.d.ts +*.spec.js +*.spec.js.map +*.spec.class.d.ts +*.spec.class.js +*.spec.class.js.map +*.sh +.istanbul.yml \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index f48d21d..42bc430 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: node_js node_js: - '5.10' +before_install: export npm_config_loglevel=silent before_deploy: npm version --no-git-tag-version `git -c core.longpaths=true describe --abbrev=0 --tags` deploy: - provider: npm @@ -9,3 +10,7 @@ deploy: on: tags: true email: "waterfoul@gmail.com" +cache: + directories: + - node_modules + - typings \ No newline at end of file diff --git a/README.md b/README.md index 04b5161..c1ac420 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ js dependancy injection. It can also be used on the frontend with a little setup and will talk via an api. [![Build Status](https://travis-ci.org/tanjentjs/ts-orm.svg?branch=master)](https://travis-ci.org/tanjentjs/ts-orm) +[![Coverage Status](https://coveralls.io/repos/github/tanjentjs/ts-orm/badge.svg?branch=master)](https://coveralls.io/github/tanjentjs/ts-orm?branch=master) +[![NPM Downloads](https://img.shields.io/npm/dm/tanjentjs-ts-orm.svg)](https://www.npmjs.com/package/tanjentjs-ts-orm) +[![Slack Status](https://tanjentjs-slack.herokuapp.com/badge.svg)](https://tanjentjs-slack.herokuapp.com/) Installing ========== @@ -26,12 +29,19 @@ Please submit all requests for features and bug requests via the github Testing ======= -TBD +* Tests will be placed along side the file in question in a file labeled .spec.ts +* Tests will be written using Mocha with the describe statement containing the name of the file including the directory + (ex. 'node/connect' for 'node/connect.ts') +* If you need to create classes for testing (ex. testing an abstract class) place them in a .spec.class.ts +* All public interfaces must be tested +* In order to fix a bug you must write a test first, this is to avoid regressions +* The test reporter should always show 100% coverage. If something doesn't make sense to test you can ignore it using special comments Directory structure ======= * shared - contains the files shared across all vendors * node - contains the files for use via nodejs +* mocks - contains mock objects for use in testing Development =========== diff --git a/angular2/DataConnection.spec.class.ts b/angular2/DataConnection.spec.class.ts new file mode 100644 index 0000000..9d8064d --- /dev/null +++ b/angular2/DataConnection.spec.class.ts @@ -0,0 +1,35 @@ +import {DataConnection} from './DataConnection'; +import {register} from '../shared/DataObject'; +import { Injector } from '@angular/core'; +import * as Contracts from './DataContract.spec.class'; + +@register('test') +export class NoProp extends DataConnection { + constructor(injector?: Injector) { super(injector); } + protected getContract() { + return Contracts.NoProp; + } +} + +@register('test') +export class NoInject extends DataConnection { + protected getContract() { + return Contracts.NoProp; + } +} + +@register('test') +export class StringProp extends DataConnection { + constructor(injector?: Injector) { super(injector); } + protected getContract() { + return Contracts.StringProp; + } +} + +@register('test') +export class DateProp extends DataConnection { + constructor(injector?: Injector) { super(injector); } + protected getContract() { + return Contracts.DateProp; + } +} diff --git a/angular2/DataConnection.spec.ts b/angular2/DataConnection.spec.ts new file mode 100644 index 0000000..ebe5a6e --- /dev/null +++ b/angular2/DataConnection.spec.ts @@ -0,0 +1,183 @@ +import {ReflectiveInjector, enableProdMode} from '@angular/core'; +import {HTTP_PROVIDERS, BaseRequestOptions, Http, Response, ResponseOptions} from '@angular/http'; +import {MockBackend, MockConnection} from '@angular/http/testing'; +import {AuthHandler} from './AuthHandler'; + +import * as chai from 'chai'; +import * as moment from 'moment'; + +import {NoProp, DateProp, StringProp, NoInject} from './DataConnection.spec.class'; +import {NoProp as NoPropContract, StringProp as StringPropContract} from './DataContract.spec.class'; + +enableProdMode(); + +describe('angular2/DataConnection', () => { + + let injector: ReflectiveInjector; + let http: Http; + let backend: MockBackend; + + beforeEach(() => { + injector = ReflectiveInjector.resolveAndCreate([ + HTTP_PROVIDERS, + { + deps: [MockBackend, BaseRequestOptions], + provide: Http, + useFactory: (mbackend: MockBackend, defaultOptions: BaseRequestOptions) => { + return new Http(mbackend, defaultOptions); + } + }, + NoProp, + StringProp, + DateProp, + NoInject, + AuthHandler, + MockBackend, + BaseRequestOptions + ]); + http = injector.get(Http); + backend = injector.get(MockBackend); + }); + + it('dies without the injector', () => { + chai.expect(() => injector.get(NoInject)).to.throw(Error); + }); + + describe('noProp', function () { + let noProp: NoProp; + + beforeEach(() => { + noProp = injector.get(NoProp); + }); + + it('fetches data', () => { + backend.connections.subscribe((c: MockConnection) => { + chai.expect(c.request.url).to.equal('/object/test.NoProp/42'); + c.mockRespond(new Response(new ResponseOptions({body: '{}'}))); + }); + return chai.expect(noProp.fetch(42)).to.eventually.be.an.instanceof(NoPropContract); + }); + + it('creates', () => { + return chai.expect(noProp.create()).be.an.instanceof(NoPropContract); + }); + + it('searches', () => { + backend.connections.subscribe((c: MockConnection) => { + chai.expect(c.request.url).to.equal('/object/test.NoProp'); + chai.expect(c.request.getBody()).to.equal('{"stuff":"things"}'); + + c.mockRespond(new Response(new ResponseOptions({body: '[{}]'}))); + }); + return noProp.search({'stuff': 'things'}).then((result: any) => { + chai.expect(result).to.be.an('array'); + chai.expect(result.length).to.equal(1); + chai.expect(result[0]).to.be.an.instanceof(NoPropContract); + }); + }); + + it('deletes', () => { + let requestNum = 0; + backend.connections.subscribe((c: MockConnection) => { + chai.expect(c.request.url).to.equal('/object/test.NoProp/42'); + if (requestNum === 0) { + chai.expect(c.request.method).to.equal(0); // GET + c.mockRespond(new Response(new ResponseOptions({body: '{"id":"42"}'}))); + } else if (requestNum === 1) { + chai.expect(c.request.method).to.equal(3); // DELETE + c.mockRespond(new Response(new ResponseOptions({body: ''}))); + } + requestNum++; + }); + return chai.expect(noProp.fetch(42).then((r) => { + return r.delete(); + })).to.eventually.be.fulfilled; + }); + }); + + describe('stringProp', function () { + let stringProp: StringProp; + + beforeEach(() => { + stringProp = injector.get(StringProp); + }); + + it('saves a new object', () => { + const cur: StringPropContract = stringProp.create(); + + cur.stringy = 'stuff'; + + backend.connections.subscribe((c: MockConnection) => { + chai.expect(c.request.url).to.equal('/object/test.StringProp'); + chai.expect(c.request.method).to.equal(2); // PUT + chai.expect(c.request.getBody()).to.equal('{"stringy":"stuff"}'); + c.mockRespond(new Response(new ResponseOptions({body: '{"id":"45"}'}))); + }); + + return chai.expect(cur.save()).to.eventually.have.property('id', '45'); + }); + + it('saves an existing object', () => { + + let requestNum = 0; + backend.connections.subscribe((c: MockConnection) => { + chai.expect(c.request.url).to.equal('/object/test.StringProp/45'); + if (requestNum === 0) { + chai.expect(c.request.method).to.equal(0); // GET + c.mockRespond(new Response(new ResponseOptions({body: '{"id":"45"}'}))); + } else if (requestNum === 1) { + chai.expect(c.request.method).to.equal(2); // PUT + chai.expect(c.request.getBody()).to.equal('{"id":"45","stringy":"stuff"}'); + c.mockRespond(new Response(new ResponseOptions({body: '{"id":"45"}'}))); + } + requestNum++; + }); + return stringProp.fetch(45).then((cur: StringPropContract) => { + cur.stringy = 'stuff'; + + return chai.expect(cur.save()).to.eventually.be.fulfilled; + }); + }); + }); + + describe('dateProp', function () { + let dateProp: DateProp; + const dateString = '2016-07-14T19:08:43.279Z'; + + beforeEach(() => { + dateProp = injector.get(DateProp); + }); + + it('serializes dates', function () { + const cur = dateProp.create(); + + cur.dateThing = moment(dateString); + + backend.connections.subscribe((c: MockConnection) => { + chai.expect(c.request.url).to.equal('/object/test.DateProp'); + chai.expect(c.request.method).to.equal(2); // PUT + chai.expect(c.request.getBody()).to.equal('{"dateThing":"' + dateString + '"}'); + c.mockRespond(new Response(new ResponseOptions({body: '{"id":"41"}'}))); + }); + + return chai.expect(cur.save()).to.eventually.have.property('id', '41'); + }); + + it('deserializes dates', () => { + backend.connections.subscribe((c: MockConnection) => { + chai.expect(c.request.url).to.equal('/object/test.DateProp/41'); + c.mockRespond(new Response(new ResponseOptions({body: '{"id":"41","dateThing":"' + dateString + '"}'}))); + }); + return dateProp.fetch(41).then((c) => { + chai.expect(c.dateThing.toISOString()).to.equal(dateString); + chai.expect(moment.isMoment(c.dateThing)).to.be.true; + }); + }); + }); + + afterEach(() => { + backend.resolveAllConnections(); + backend.verifyNoPendingRequests(); + }); + +}); diff --git a/angular2/DataObject.ts b/angular2/DataConnection.ts similarity index 50% rename from angular2/DataObject.ts rename to angular2/DataConnection.ts index 6083cc3..7f6a579 100644 --- a/angular2/DataObject.ts +++ b/angular2/DataConnection.ts @@ -1,72 +1,10 @@ -import { IDataConnection, IDataContract } from '../shared/DataObject'; -import { Http, Headers, Response, RequestOptions } from '@angular/http'; +import { IDataConnection } from '../shared/DataObject'; +import { Http, Response } from '@angular/http'; import { Injector } from '@angular/core'; -import * as moment from 'moment'; import * as _ from 'lodash'; -import {Types} from '../shared/Types'; - -import {field} from './field'; import {AuthHandler} from './AuthHandler'; - -import 'rxjs/add/operator/map'; -import 'rxjs/add/operator/toPromise'; -import { Observable } from 'rxjs/Observable'; - -export abstract class DataContract implements IDataContract { - private get fields(): string[] { - return Reflect.getMetadata('ORM:fields', this); - } - - @field() - public id: number; - @field(Types.dateTimeTz) - public createdAt: moment.Moment; - @field(Types.dateTimeTz) - public updatedAt: moment.Moment; - - constructor( - private http: Http, - private auth: AuthHandler, - private baseUri: string, - private data: any = {} - ) {} - - public save(): Promise { - // TODO: optimistic locking - let request: Observable; - if (this.id) { - request = this.http.put( - this.baseUri + '/' + this.id, - JSON.stringify(this.data), - this.auth.setOptions(getOptions()) - ); - } else { - request = this.http.put( - this.baseUri, - JSON.stringify(this.data), - this.auth.setOptions(getOptions()) - ); - } - return request - .map((res: Response) => this.auth.handleResponse(res)) - .map((res: Response) => { - this.data = res.json(); - }) - .toPromise(); - } - - public delete(): Promise { - return this.http - .delete( - this.baseUri + '/' + this.id, - this.auth.setOptions(getOptions()) - ) - .map((res: Response) => this.auth.handleResponse(res)) - .toPromise() - .then(() => { /* */ }); - } -} +import {DataContract, getOptions} from './DataContract'; export abstract class DataConnection implements IDataConnection { private http: Http = this.injector && this.injector.get(Http); @@ -96,7 +34,7 @@ export abstract class DataConnection implements IDataCon this.http, this.auth, this.baseUri, - body || { } + body ); }).toPromise(); } @@ -140,12 +78,3 @@ export abstract class DataConnection implements IDataCon data?: any ) => T; } - -function getOptions(): RequestOptions { - const headers: Headers = new Headers(); - headers.set('accept', 'application/json'); - - return new RequestOptions({ - headers: headers - }); -} diff --git a/angular2/DataContract.spec.class.ts b/angular2/DataContract.spec.class.ts new file mode 100644 index 0000000..ae7cb45 --- /dev/null +++ b/angular2/DataContract.spec.class.ts @@ -0,0 +1,15 @@ +import {DataContract} from './DataContract'; +import {field} from './field'; +import {Types} from '../shared/Types'; + +import * as moment from 'moment'; + +export class NoProp extends DataContract {} +export class StringProp extends DataContract { + @field() + public stringy: string; +} +export class DateProp extends DataContract { + @field(Types.dateTimeTz) + public dateThing: moment.Moment; +} diff --git a/angular2/DataContract.ts b/angular2/DataContract.ts new file mode 100644 index 0000000..b73c488 --- /dev/null +++ b/angular2/DataContract.ts @@ -0,0 +1,73 @@ +import { IDataContract } from '../shared/DataObject'; +import { Http, Response, RequestOptions, Headers } from '@angular/http'; +import * as moment from 'moment'; + +import {Types} from '../shared/Types'; + +import {field} from './field'; +import {AuthHandler} from './AuthHandler'; + +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/toPromise'; +import { Observable } from 'rxjs/Observable'; + +export abstract class DataContract implements IDataContract { + @field() + public id: number; + @field(Types.dateTimeTz) + public createdAt: moment.Moment; + @field(Types.dateTimeTz) + public updatedAt: moment.Moment; + + constructor( + private http: Http, + private auth: AuthHandler, + private baseUri: string, + private data: any = {} + ) {} + + public save(): Promise { + // TODO: optimistic locking + let request: Observable; + if (this.id) { + request = this.http.put( + this.baseUri + '/' + this.id, + JSON.stringify(this.data), + this.auth.setOptions(getOptions()) + ); + } else { + request = this.http.put( + this.baseUri, + JSON.stringify(this.data), + this.auth.setOptions(getOptions()) + ); + } + return request + .map((res: Response) => this.auth.handleResponse(res)) + .map((res: Response) => { + this.data = res.json(); + return this; + }) + .toPromise(); + } + + public delete(): Promise { + return this.http + .delete( + this.baseUri + '/' + this.id, + this.auth.setOptions(getOptions()) + ) + .map((res: Response) => this.auth.handleResponse(res)) + .toPromise() + .then(() => { /* */ }); + } +} + +export function getOptions(): RequestOptions { + const headers: Headers = new Headers(); + headers.set('accept', 'application/json'); + + return new RequestOptions({ + headers: headers + }); +} diff --git a/angular2/field.ts b/angular2/field.ts index 05da388..4637793 100644 --- a/angular2/field.ts +++ b/angular2/field.ts @@ -5,25 +5,23 @@ import {Types} from '../shared/Types'; export function field(type?: Types): (target: any, key: string) => any { return function field(target: any, key: string) { - let val = target[key]; - // property getter const getter = function () { - if (!val) { + if (!this['_' + key]) { switch (type) { case(Types.dateTimeTz): - val = moment(this.data[key]); + this['_' + key] = moment(this.data[key]); break; default: - val = this.data[key]; + this['_' + key] = this.data[key]; } } - return val; + return this['_' + key]; }; // property setter const setter = function (newVal) { - val = newVal; + this['_' + key] = newVal; switch (type) { case(Types.dateTimeTz): this.data[key] = newVal.toISOString(); diff --git a/angular2/index.ts b/angular2/index.ts index 39c7e7d..adaddf6 100644 --- a/angular2/index.ts +++ b/angular2/index.ts @@ -1,4 +1,5 @@ -export {DataConnection, DataContract} from './DataObject'; +export {DataContract} from './DataContract'; +export {DataConnection} from './DataConnection'; export {field} from './field'; export {register} from '../shared/DataObject'; export {AuthHandler} from './AuthHandler'; diff --git a/mochaInit.ts b/mochaInit.ts new file mode 100644 index 0000000..a892de8 --- /dev/null +++ b/mochaInit.ts @@ -0,0 +1,22 @@ +import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +chai.use(chaiAsPromised); +require('source-map-support').install(); + +/** + * This makes the "loading non-allowed module" warnings from mockery fatal + */ +const origwarn = console.warn; +console.warn = function(...args: any[]) { + if ( + args.length === 1 && + args[0].startsWith && + args[0].startsWith('WARNING: loading non-allowed module:') + ) { + chai.expect(args[0].replace('WARNING: loading non-allowed module: ', '')) + .to.equal('', 'Loading non-allowed module'); + } else { + origwarn.apply(console, args); + } +}; diff --git a/mocks/Sequelize.ts b/mocks/Sequelize.ts new file mode 100644 index 0000000..d880d96 --- /dev/null +++ b/mocks/Sequelize.ts @@ -0,0 +1,133 @@ +import * as sinon from 'sinon'; +import * as sequelize from 'sequelize'; +import * as _ from 'lodash'; + +export class Sequelize implements sequelize.SequelizeStaticAndInstance { + public Utils: typeof sequelize.Utils; + public Promise; + public QueryTypes: sequelize.QueryTypes; + public Validator: any; + public Model: sequelize.Model; + public Transaction: sequelize.TransactionStatic; + public Deferrable: sequelize.Deferrable; + public Instance: sequelize.Instance; + public Error: sequelize.BaseError; + public ValidationError: sequelize.ValidationError; + public ValidationErrorItem: sequelize.ValidationErrorItem; + public DatabaseError: sequelize.DatabaseError; + public TimeoutError: sequelize.TimeoutError; + public UniqueConstraintError: sequelize.UniqueConstraintError; + public ExclusionConstraintError: sequelize.ExclusionConstraintError; + public ForeignKeyConstraintError: sequelize.ForeignKeyConstraintError; + public ConnectionError: sequelize.ConnectionError; + public ConnectionRefusedError: sequelize.ConnectionRefusedError; + public AccessDeniedError: sequelize.AccessDeniedError; + public HostNotFoundError: sequelize.HostNotFoundError; + public HostNotReachableError: sequelize.HostNotReachableError; + public InvalidConnectionError: sequelize.InvalidConnectionError; + public ConnectionTimedOutError: sequelize.ConnectionTimedOutError; + + public fn: sinon.SinonStub = sinon.stub(); + public col: sinon.SinonStub = sinon.stub(); + public cast: sinon.SinonStub = sinon.stub(); + public literal: sinon.SinonStub = sinon.stub(); + public asIs: sinon.SinonStub = sinon.stub(); + public and: sinon.SinonStub = sinon.stub(); + public or: sinon.SinonStub = sinon.stub(); + public json: sinon.SinonStub = sinon.stub(); + public where: sinon.SinonStub = sinon.stub(); + public condition: sinon.SinonStub = sinon.stub(); + public define: sinon.SinonStub = sinon.stub(); + + constructor( + public database, + public username, + public password, + public options + ) {} +} + +_.forEach(sequelize, (val, key) => { + if (key === key.toUpperCase()) { + Sequelize[key] = val; + } +}); + +export class Model implements sequelize.Model { + public Instance: sinon.SinonStub = sinon.stub(); + public removeAttribute: sinon.SinonStub = sinon.stub(); + public sync: sinon.SinonStub = sinon.stub(); + public drop: sinon.SinonStub = sinon.stub(); + public schema: sinon.SinonStub = sinon.stub(); + public getTableName: sinon.SinonStub = sinon.stub(); + public scope: sinon.SinonStub = sinon.stub(); + public findAll: sinon.SinonStub = sinon.stub(); + public all: sinon.SinonStub = sinon.stub(); + public findById: sinon.SinonStub = sinon.stub(); + public findByPrimary: sinon.SinonStub = sinon.stub(); + public findOne: sinon.SinonStub = sinon.stub(); + public find: sinon.SinonStub = sinon.stub(); + public aggregate: sinon.SinonStub = sinon.stub(); + public count: sinon.SinonStub = sinon.stub(); + public findAndCount: sinon.SinonStub = sinon.stub(); + public findAndCountAll: sinon.SinonStub = sinon.stub(); + public max: sinon.SinonStub = sinon.stub(); + public min: sinon.SinonStub = sinon.stub(); + public sum: sinon.SinonStub = sinon.stub(); + public build: sinon.SinonStub = sinon.stub(); + public bulkBuild: sinon.SinonStub = sinon.stub(); + public create: sinon.SinonStub = sinon.stub(); + public findOrInitialize: sinon.SinonStub = sinon.stub(); + public findOrBuild: sinon.SinonStub = sinon.stub(); + public findOrCreate: sinon.SinonStub = sinon.stub(); + public upsert: sinon.SinonStub = sinon.stub(); + public insertOrUpdate: sinon.SinonStub = sinon.stub(); + public bulkCreate: sinon.SinonStub = sinon.stub(); + public truncate: sinon.SinonStub = sinon.stub(); + public destroy: sinon.SinonStub = sinon.stub(); + public restore: sinon.SinonStub = sinon.stub(); + public update: sinon.SinonStub = sinon.stub(); + public describe: sinon.SinonStub = sinon.stub(); + public unscoped: sinon.SinonStub = sinon.stub(); + public addHook: sinon.SinonStub = sinon.stub(); + public hook: sinon.SinonStub = sinon.stub(); + public removeHook: sinon.SinonStub = sinon.stub(); + public hasHook: sinon.SinonStub = sinon.stub(); + public hasHooks: sinon.SinonStub = sinon.stub(); + public beforeValidate: sinon.SinonStub = sinon.stub(); + public afterValidate: sinon.SinonStub = sinon.stub(); + public beforeCreate: sinon.SinonStub = sinon.stub(); + public afterCreate: sinon.SinonStub = sinon.stub(); + public beforeDestroy: sinon.SinonStub = sinon.stub(); + public beforeDelete: sinon.SinonStub = sinon.stub(); + public afterDestroy: sinon.SinonStub = sinon.stub(); + public afterDelete: sinon.SinonStub = sinon.stub(); + public beforeUpdate: sinon.SinonStub = sinon.stub(); + public afterUpdate: sinon.SinonStub = sinon.stub(); + public beforeBulkCreate: sinon.SinonStub = sinon.stub(); + public afterBulkCreate: sinon.SinonStub = sinon.stub(); + public beforeBulkDestroy: sinon.SinonStub = sinon.stub(); + public beforeBulkDelete: sinon.SinonStub = sinon.stub(); + public afterBulkDestroy: sinon.SinonStub = sinon.stub(); + public afterBulkDelete: sinon.SinonStub = sinon.stub(); + public beforeBulkUpdate: sinon.SinonStub = sinon.stub(); + public afterBulkUpdate: sinon.SinonStub = sinon.stub(); + public beforeFind: sinon.SinonStub = sinon.stub(); + public beforeFindAfterExpandIncludeAll: sinon.SinonStub = sinon.stub(); + public beforeFindAfterOptions: sinon.SinonStub = sinon.stub(); + public afterFind: sinon.SinonStub = sinon.stub(); + public beforeDefine: sinon.SinonStub = sinon.stub(); + public afterDefine: sinon.SinonStub = sinon.stub(); + public beforeInit: sinon.SinonStub = sinon.stub(); + public afterInit: sinon.SinonStub = sinon.stub(); + public beforeBulkSync: sinon.SinonStub = sinon.stub(); + public afterBulkSync: sinon.SinonStub = sinon.stub(); + public beforeSync: sinon.SinonStub = sinon.stub(); + public afterSync: sinon.SinonStub = sinon.stub(); + public hasOne: sinon.SinonStub = sinon.stub(); + public belongsTo: sinon.SinonStub = sinon.stub(); + public hasMany: sinon.SinonStub = sinon.stub(); + public belongsToMany: sinon.SinonStub = sinon.stub(); + + constructor(public tableName: string) {} +} diff --git a/mocks/connect.ts b/mocks/connect.ts new file mode 100644 index 0000000..a6b3dbc --- /dev/null +++ b/mocks/connect.ts @@ -0,0 +1,22 @@ +import * as sinon from 'sinon'; + +export const model = { + create: sinon.stub(), + findAll: sinon.stub(), + findById: sinon.stub(), + sync: sinon.stub() +}; + +export function reset(connect: any) { + model.create = sinon.stub(); + + model.findById = sinon.stub(); + model.findById.returns(Promise.resolve(null)); + + model.findAll = sinon.stub(); + model.findAll.returns(Promise.resolve([])); + + model.sync = sinon.stub(); + + connect.connection.define.returns(model); +} diff --git a/node/DataConnection.spec.class.ts b/node/DataConnection.spec.class.ts new file mode 100644 index 0000000..b0703be --- /dev/null +++ b/node/DataConnection.spec.class.ts @@ -0,0 +1,47 @@ +import {DataConnection} from './DataConnection'; +import {register} from '../shared/DataObject'; +import * as Contracts from './DataContract.spec.class'; + +@register('test') +export class NoProp extends DataConnection { + protected getContract() { + return Contracts.NoProp; + } +} + +@register('test') +export class StringProp extends DataConnection { + protected getContract() { + return Contracts.StringProp; + } +} + +export class FloatProp extends DataConnection { + protected getContract() { + return Contracts.FloatProp; + } +} + +export class IntProp extends DataConnection { + protected getContract() { + return Contracts.IntProp; + } +} + +export class BigIntProp extends DataConnection { + protected getContract() { + return Contracts.BigIntProp; + } +} + +export class DateProp extends DataConnection { + protected getContract() { + return Contracts.DateProp; + } +} + +export class BadProp extends DataConnection { + protected getContract() { + return Contracts.BadProp; + } +} diff --git a/node/DataConnection.spec.ts b/node/DataConnection.spec.ts new file mode 100644 index 0000000..aeae717 --- /dev/null +++ b/node/DataConnection.spec.ts @@ -0,0 +1,267 @@ +import * as mockery from 'mockery'; +import * as chai from 'chai'; +import * as classTypes from './DataConnection.spec.class'; +import * as sequelize from 'sequelize'; +import * as mockConnect from '../mocks/connect'; + +import {Sequelize} from '../mocks/Sequelize'; + +describe('node/DataConnection', function() { + let connect: any; + let classes: any; + + before(function(){ + mockery.enable(); + mockery.registerMock('sequelize', Sequelize); + mockery.registerAllowables([ + './connect', + './DataContract', + './DataConnection', + './DataContract.spec.class', + './DataConnection.spec.class', + '../shared/DataObject', + './field', + '../shared/field', + '../shared/Types', + './Types', + 'moment', + 'lodash', + 'bunyan', + 'os', + 'fs', + 'util', + 'assert', + 'events', + 'stream' + ]); + + connect = require('./connect'); + connect.connect('a', 'b', 'c'); + + classes = require('./DataConnection.spec.class'); + }); + + beforeEach(function () { + mockConnect.reset(connect); + + mockConnect.model.findById.withArgs(42).returns(Promise.resolve({ + get: () => 42 + })); + mockConnect.model.findAll.withArgs({ + include: [{all: true}], + where: { + find: true + } + }).returns(Promise.resolve([ + { + get: () => 40 + }, + { + get: () => 41 + }, + { + get: () => 42 + } + ])); + }); + + describe('constructor', function() { + beforeEach(function () { + connect.connection.define.reset(); + }); + + describe('noProp', function () { + let current: classTypes.NoProp; + + beforeEach(function () { + current = new classes.NoProp(); + }); + + it('defines the connection', function () { + const calls = connect.connection.define.getCalls(); + + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal([ + "NoProp", + {}, + { + "freezeTableName": true + } + ]); + }); + }); + + describe('stringProp', function () { + let current: classTypes.StringProp; + + beforeEach(function () { + current = new classes.StringProp(); + }); + + it('defines the connection', function () { + const calls = connect.connection.define.getCalls(); + + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal([ + "StringProp", + { + "stringy": { + type: sequelize.STRING + } + }, + { + "freezeTableName": true + } + ]); + }); + }); + + describe('floatProp', function () { + let current: classTypes.FloatProp; + + beforeEach(function () { + current = new classes.FloatProp(); + }); + + it('defines the connection', function () { + const calls = connect.connection.define.getCalls(); + + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal([ + "FloatProp", + { + "floaty": { + type: sequelize.FLOAT + } + }, + { + "freezeTableName": true + } + ]); + }); + }); + + describe('intProp', function () { + let current: classTypes.IntProp; + + beforeEach(function () { + current = new classes.IntProp(); + }); + + it('defines the connection', function () { + const calls = connect.connection.define.getCalls(); + + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal([ + "IntProp", + { + "inty": { + type: sequelize.INTEGER + } + }, + { + "freezeTableName": true + } + ]); + }); + }); + + describe('bigIntProp', function () { + let current: classTypes.BigIntProp; + + beforeEach(function () { + current = new classes.BigIntProp(); + }); + + it('defines the connection', function () { + const calls = connect.connection.define.getCalls(); + + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal([ + "BigIntProp", + { + "inty": { + type: sequelize.BIGINT + } + }, + { + "freezeTableName": true + } + ]); + }); + }); + + describe('dateProp', function () { + let current: classTypes.DateProp; + + beforeEach(function () { + current = new classes.DateProp(); + }); + + it('defines the connection', function () { + const calls = connect.connection.define.getCalls(); + + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal([ + "DateProp", + { + "dateThing": { + type: sequelize.DATE + } + }, + { + "freezeTableName": true + } + ]); + }); + }); + + describe('badProp', function () { + + it('throws an error', function () { + chai.expect(() => { + const a = new classes.BadProp(); + a.create(); + }).to.throw(TypeError); + }); + }); + }); + + describe('noProp', function () { + let current: classTypes.NoProp; + + beforeEach(function () { + current = new classes.NoProp(); + }); + + it('creates', function () { + const thing = current.create(); + chai.expect(thing).to.not.be.null; + }); + + it('rejects on bad ids', function () { + return chai.expect(current.fetch(40)).to.eventually.be.rejectedWith('Not Found'); + }); + + it('resolves on good ids', function () { + return chai.expect(current.fetch(42)).to.eventually.be.fulfilled; + }); + + it('returns empty', function () { + return chai.expect(current.search({ + find: false + })).to.eventually.deep.equal([]); + }); + + it('returns full', function () { + return chai.expect(current.search({ + find: true + })).to.eventually.have.deep.property('[2].id', 42); + }); + }); + + after(function() { + mockery.deregisterAll(); + mockery.disable(); + }); +}); diff --git a/node/DataConnection.ts b/node/DataConnection.ts new file mode 100644 index 0000000..6870455 --- /dev/null +++ b/node/DataConnection.ts @@ -0,0 +1,143 @@ +// DO NOT REMOVE THIS IMPORT it is required for this file to function +// tslint:disable-next-line:no-unused-variable +import * as reflectMetadata from 'reflect-metadata'; +import * as sequelize from 'sequelize'; +import * as _ from 'lodash'; + +import { Types } from '../shared/Types'; +import { IDataConnection } from '../shared/DataObject'; +import { connection } from './connect'; +import {DataContract} from './DataContract'; + +export abstract class DataConnection implements IDataConnection { + + private static syncedModels: { + [modelName: string]: sequelize.Model; + } = {}; + + private _dummyContract: T = null; + private get dummyContract(): T { + if (!this._dummyContract) { + this._dummyContract = new (this.getContract())(null, null); + } + return this._dummyContract; + } + + private _fields: string[] = []; + private get fields(): string[] { + if (!this._fields.length) { + this._fields = Reflect.getMetadata('ORM:fields', this.dummyContract); + } + return this._fields; + } + + // This is used in some of the decorators + // tslint:disable-next-line:no-unused-variable + private instance: any = null; + + private get model(): sequelize.Model { + return DataConnection.syncedModels[( this.constructor).name]; + } + private set model(val: sequelize.Model) { + DataConnection.syncedModels[( this.constructor).name] = val; + // TODO make updates more graceful + val.sync(); + } + + constructor(injector?: any) { + let className = ( this.constructor).name; + + if (!this.model) { + const model: any = {}; + const fields: string[] = this.fields; + _.forEach(fields, (fieldName) => { + const type: Types = Reflect.getMetadata( + "ORM:type", + this.dummyContract, + fieldName + ); + + switch (type) { + case Types.string: + model[fieldName] = { + type: sequelize.STRING + }; + break; + case Types.float: + model[fieldName] = { + type: sequelize.FLOAT + }; + break; + case Types.integer: + model[fieldName] = { + type: sequelize.INTEGER + }; + break; + case Types.bigInt: + model[fieldName] = { + type: sequelize.BIGINT + }; + break; + case Types.dateTimeTz: + model[fieldName] = { + type: sequelize.DATE + }; + break; + default: + throw new TypeError( + 'Field of unknown type found! ' + + 'Field Name:' + fieldName + ' ' + + 'Field Type: ' + type + ); + } + }); + this.model = connection.define( + className, + model, + { + freezeTableName: true // Model tableName will be the same as the model name + } + ); + } + } + + public fetch(id: number): Promise { + return this.model.findById(id) + .then((sqlData: any): Promise | T => { + if ( sqlData === null ) { + return Promise.reject('Not Found'); + } else { + return new (this.getContract())(sqlData, this.model); + } + }); + } + + public create(): T { + return new (this.getContract())(null, this.model); + } + + public search( + criteria: sequelize.WhereOptions | Array + ): Promise { + return this.model + .findAll({ + include: [{ all: true }], + where: criteria + }) + .then((data: any[]) => { + let ret: T[] = []; + _.forEach(data, (value: any) => { + ret.push(new (this.getContract())(value, this.model)); + }); + return ret; + }); + } + + /** + * This feeds the data contract into the system + */ + protected abstract getContract(): new( + instance: any, + model: sequelize.Model + ) => T; +} diff --git a/node/DataContract.spec.class.ts b/node/DataContract.spec.class.ts new file mode 100644 index 0000000..07de4b4 --- /dev/null +++ b/node/DataContract.spec.class.ts @@ -0,0 +1,31 @@ +import {DataContract} from './DataContract'; +import {field} from './field'; +import {Types} from '../shared/Types'; + +import * as moment from 'moment'; + +export class NoProp extends DataContract {} +export class StringProp extends DataContract { + @field() + public stringy: string; +} +export class FloatProp extends DataContract { + @field() + public floaty: number; +} +export class IntProp extends DataContract { + @field(Types.integer) + public inty: number; +} +export class BigIntProp extends DataContract { + @field(Types.bigInt) + public inty: number; +} +export class DateProp extends DataContract { + @field(Types.dateTimeTz) + public dateThing: moment.Moment; +} +export class BadProp extends DataContract { + @field( 'a') + public dateThing: moment.Moment; +} diff --git a/node/DataContract.spec.ts b/node/DataContract.spec.ts new file mode 100644 index 0000000..a143d10 --- /dev/null +++ b/node/DataContract.spec.ts @@ -0,0 +1,240 @@ +import * as mockery from 'mockery'; +import * as chai from 'chai'; +import * as classTypes from './DataContract.spec.class'; +import * as moment from 'moment'; +import * as sinon from 'sinon'; + +import {Sequelize, Model} from '../mocks/Sequelize'; + +describe('node/DataContract', function() { + let connect: any; + let classes: any; + + before(function(){ + mockery.enable(); + mockery.registerMock('sequelize', Sequelize); + mockery.registerAllowables([ + './connect', + './DataContract', + './DataContract.spec.class', + './field', + '../shared/field', + '../shared/Types', + './Types', + 'moment', + 'lodash' + ]); + + connect = require('./connect'); + connect.connect('a', 'b', 'c'); + + classes = require('./DataContract.spec.class'); + }); + + describe('existing', function() { + let model: Model; + let instance: any; + + beforeEach(function () { + model = new Model('existing'); + instance = { + destroy: sinon.stub(), + get: sinon.stub(), + save: sinon.stub(), + set: sinon.stub() + }; + instance.destroy.returns(Promise.resolve()); + instance.save.returns(Promise.resolve()); + }); + + describe('noProp', function () { + let current: classTypes.NoProp; + + beforeEach(function () { + current = new classes.NoProp(instance, model); + }); + + it('saves', function () { + current.save(); + chai.expect(instance.save.getCalls().length).to.equal(1); + }); + + it('destroys', function () { + current.delete(); + chai.expect(instance.destroy.getCalls().length).to.equal(1); + }); + }); + + describe('stringProp', function() { + let current: classTypes.StringProp; + + beforeEach(function () { + current = new classes.StringProp(instance, model); + }); + + it('updates the model', function () { + current.stringy = 'thingy'; + const calls = instance.set.getCalls(); + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal(['stringy', 'thingy']); + }); + + it('fetches from the model', function () { + instance.get.withArgs('stringy').returns('thingy'); + chai.expect(current.stringy).to.equal('thingy'); + }); + }); + + describe('dateProp', function() { + let current: classTypes.DateProp; + const isoDate = '2016-07-01T18:25:06.094Z'; + + beforeEach(function () { + current = new classes.DateProp(instance, model); + }); + + it('updates the model', function () { + current.dateThing = moment(isoDate); + const calls = instance.set.getCalls(); + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal(['dateThing', isoDate]); + }); + + it('fetches from the model', function () { + instance.get.withArgs('dateThing').returns(isoDate); + chai.expect(current.dateThing.toISOString()).to.equal(isoDate); + }); + + it('handles invalid dates', function () { + current.dateThing = moment.invalid(); + const calls = instance.set.getCalls(); + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal(['dateThing', null]); + }); + + it('handles bad assigns', function () { + current.dateThing = 'Invalid Date'; + const calls = instance.set.getCalls(); + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal(['dateThing', null]); + }); + }); + }); + + describe('new', function() { + let model: Model; + + beforeEach(function () { + model = new Model('new'); + }); + + describe('noProp', function () { + let current: classTypes.NoProp; + + beforeEach(function () { + current = new classes.NoProp(null, model); + }); + + describe('serialize', function () { + it('empty object', function () { + chai.expect(current.serialize()).to.equal('{}'); + }); + + it('id', function () { + current.id = 0; + chai.expect(current.serialize()).to.equal('{"id":0}'); + }); + + it('dates in iso format', function () { + const isoDate = '2016-07-01T18:25:06.094Z'; + const isoDate2 = '2016-07-02T18:25:06.094Z'; + current.createdAt = moment(isoDate); + current.updatedAt = moment(isoDate2); + chai.expect(current.serialize()).to.equal( + '{' + + '"createdAt":"' + isoDate + '",' + + '"updatedAt":"' + isoDate2 + '"' + + '}' + ); + }); + }); + + describe('save', function () { + it('creates an object', function () { + model.create.returns(Promise.resolve({})); + current.save(); + + const calls = model.create.getCalls(); + chai.expect(calls.length).to.equal(1); + chai.expect(calls[0].args).to.deep.equal([{}]); + }); + }); + + describe('delete', function () { + it('returns a resolved promise', function () { + return current.delete(); + }); + }); + }); + + describe('stringProp', function() { + let current: classTypes.StringProp; + + beforeEach(function () { + current = new classes.StringProp(null, model); + }); + + describe('serialize', function () { + it('empty object', function () { + chai.expect(current.serialize()).to.equal('{}'); + }); + + it('value', function () { + current.stringy = 'stuff'; + chai.expect(current.serialize()).to.equal('{"stringy":"stuff"}'); + }); + }); + + describe('loadData', function () { + it('loads data', function () { + current.loadData({stringy: 'thingy'}); + chai.expect(current.stringy).to.equal('thingy'); + }); + }); + }); + + describe('dateProp', function() { + let current: classTypes.DateProp; + + beforeEach(function () { + current = new classes.DateProp(null, model); + }); + + describe('serialize', function () { + it('empty object', function () { + chai.expect(current.serialize()).to.equal('{}'); + }); + + it('value', function () { + const isoDate = '2016-07-01T18:25:06.094Z'; + current.dateThing = moment(isoDate); + chai.expect(current.serialize()) + .to.equal('{"dateThing":"' + isoDate + '"}'); + }); + }); + + describe('loadData', function () { + it('loads data', function () { + const isoDate = '2016-07-01T18:25:06.094Z'; + current.loadData({dateThing: isoDate}); + chai.expect(current.dateThing.toISOString()).to.equal(isoDate); + }); + }); + }); + }); + + after(function() { + mockery.deregisterAll(); + mockery.disable(); + }); +}); diff --git a/node/DataContract.ts b/node/DataContract.ts new file mode 100644 index 0000000..e892617 --- /dev/null +++ b/node/DataContract.ts @@ -0,0 +1,93 @@ +// DO NOT REMOVE THIS IMPORT it is required for this file to function +// tslint:disable-next-line:no-unused-variable +import * as reflectMetadata from 'reflect-metadata'; +import * as sequelize from 'sequelize'; +import * as moment from 'moment'; +import * as _ from 'lodash'; + +import { field } from './field'; +import { Types } from '../shared/Types'; +import { IDataContract } from '../shared/DataObject'; + +export abstract class DataContract implements IDataContract { + private get fields(): string[] { + return Reflect.getMetadata('ORM:fields', this); + } + + @field() + public id: number; + @field(Types.dateTimeTz) + public createdAt: moment.Moment; + @field(Types.dateTimeTz) + public updatedAt: moment.Moment; + + constructor( + private instance: any, + private model: sequelize.Model + ) {} + + public loadData(data: any): void { + const fields: string[] = this.fields; + for (const i in fields) { + if (data[fields[i]] !== undefined) { + // TODO: validation + const type: Types = Reflect.getMetadata("ORM:type", this, fields[i]); + switch (type) { + case(Types.dateTimeTz): + this[fields[i]] = moment(data[fields[i]]); + break; + default: + this[fields[i]] = data[fields[i]]; + } + } + } + } + + public serialize(): string { + return JSON.stringify(this.toJSON()); + } + + public save(): Promise { + if (this.instance) { + return this.instance.save().then(() => this); + } else { + return this.model.create(this.getFields()).then((sqlData: any) => { + this.instance = sqlData; + return this; + }); + } + } + + public delete(): Promise { + if (this.instance) { + return this.instance.destroy(); + } else { + return Promise.resolve(); + } + } + + private toJSON(): any { + const returnObj: any = this.getFields(); + returnObj.id = this.id; + returnObj.createdAt = this.createdAt && this.createdAt.toISOString(); + returnObj.updatedAt = this.updatedAt && this.updatedAt.toISOString(); + return returnObj; + } + + private getFields(): any { + const returnObj: any = {}; + const fields: string[] = this.fields; + _.forEach(fields, (fieldName: string) => { + const value: any = this[fieldName]; + const type: Types = Reflect.getMetadata("ORM:type", this, fieldName); + switch (type) { + case(Types.dateTimeTz): + returnObj[fieldName] = value && value.toISOString(); + break; + default: + returnObj[fieldName] = value; + } + }); + return returnObj; + } +} diff --git a/node/DataObject.ts b/node/DataObject.ts deleted file mode 100644 index fc367e3..0000000 --- a/node/DataObject.ts +++ /dev/null @@ -1,241 +0,0 @@ -// DO NOT REMOVE THIS IMPORT it is required for this file to function -// tslint:disable-next-line:no-unused-variable -import * as reflectMetadata from 'reflect-metadata'; -import * as sequelize from 'sequelize'; -import * as moment from 'moment'; -import * as _ from 'lodash'; -import * as bunyan from 'bunyan'; - -import { field } from './field'; -import { Types } from '../shared/Types'; -import { IDataConnection, IDataContract, registeredClasses } from '../shared/DataObject'; -import { connection } from './connect'; - -const logger = bunyan.createLogger({name: "ORM/DataObject"}); - -export function getInject() { - return Array.from(registeredClasses.values()); -} - -export abstract class DataContract implements IDataContract { - private get fields(): string[] { - return Reflect.getMetadata('ORM:fields', this); - } - - @field() - public id: number; - @field(Types.dateTimeTz) - public createdAt: moment.Moment; - @field(Types.dateTimeTz) - public updatedAt: moment.Moment; - - constructor( - private instance: any, - private model: sequelize.Model - ) {} - - public loadData(data: any): void { - const fields: string[] = this.fields; - for (const i in fields) { - if (data[fields[i]] !== undefined) { - // TODO: validation - const type: Types = Reflect.getMetadata("ORM:type", this, fields[i]); - switch (type) { - case(Types.dateTimeTz): - this[fields[i]] = moment(data[fields[i]]); - break; - default: - this[fields[i]] = data[fields[i]]; - } - } - } - } - - public serialize(): string { - return JSON.stringify(this.toJSON()); - } - - public save(): Promise { - if (this.instance) { - return this.instance.save(); - } else { - return this.model.create(this.getFields()).then((sqlData: any) => { - this.instance = sqlData; - }); - } - } - - public delete(): Promise { - if (this.instance) { - return this.instance.destroy(); - } else { - return Promise.resolve(); - } - } - - private toJSON(): any { - const returnObj: any = this.getFields(); - returnObj.id = this.id; - returnObj.createdAt = this.createdAt && this.createdAt.toISOString(); - returnObj.updatedAt = this.updatedAt && this.updatedAt.toISOString(); - return returnObj; - } - - private getFields(): any { - const returnObj: any = {}; - const fields: string[] = this.fields; - _.forEach(fields, (fieldName: string) => { - const value: any = this[fieldName]; - const type: Types = Reflect.getMetadata("ORM:type", this, fieldName); - switch (type) { - case(Types.dateTimeTz): - returnObj[fieldName] = value.toISOString(); - break; - default: - returnObj[fieldName] = value; - } - }); - return returnObj; - } -} - -export abstract class DataConnection implements IDataConnection { - - private static syncedModels: { - [modelName: string]: sequelize.Model; - } = {}; - - private _dummyContract: T = null; - private get dummyContract(): T { - if (!this._dummyContract) { - this._dummyContract = new (this.getContract())(null, null); - } - return this._dummyContract; - } - - private _fields: string[] = []; - private get fields(): string[] { - if (!this._fields.length) { - this._fields = Reflect.getMetadata('ORM:fields', this.dummyContract); - } - return this._fields; - } - - private _promise: Promise = null; - public get promise(): Promise { - return this._promise; - } - - // This is used in some of the decorators - // tslint:disable-next-line:no-unused-variable - private instance: any = null; - - private get model(): sequelize.Model { - return DataConnection.syncedModels[( this.constructor).name]; - } - private set model(val: sequelize.Model) { - DataConnection.syncedModels[( this.constructor).name] = val; - // TODO make updates more graceful - val.sync(); - } - - constructor(injector?: any) { - let className = ( this.constructor).name; - - if (!this.model) { - const model: any = {}; - const fields: string[] = this.fields; - _.forEach(fields, (fieldName) => { - const type: Types = Reflect.getMetadata( - "ORM:type", - this.dummyContract, - fieldName - ); - - switch (type) { - case Types.string: - model[fieldName] = { - type: sequelize.STRING - }; - break; - case Types.float: - model[fieldName] = { - type: sequelize.FLOAT - }; - break; - case Types.integer: - model[fieldName] = { - type: sequelize.INTEGER - }; - break; - case Types.bigInt: - model[fieldName] = { - type: sequelize.BIGINT - }; - break; - case Types.dateTimeTz: - model[fieldName] = { - type: sequelize.DATE - }; - break; - default: - logger.error('Field of unknown type found!', { - fieldName: fieldName, - fieldType: type - }); - break; - } - }); - this.model = connection.define( - className, - model, - { - freezeTableName: true // Model tableName will be the same as the model name - } - ); - } - } - - public fetch(id: number): Promise { - return new Promise((res, rej) => { - this.model.findById(id).then(res, rej); - }).then((sqlData: any): Promise | T => { - if ( sqlData === null ) { - return Promise.reject('Not Found'); - } else { - return new (this.getContract())(sqlData, this.model); - } - }); - } - - public create(): T { - return new (this.getContract())(null, this.model); - } - - public search( - criteria: sequelize.WhereOptions | Array - ): Promise { - return new Promise((resolve, reject) => { - this.model - .findAll({ - include: [{ all: true }], - where: criteria - }) - .then((data: any[]) => { - let ret: T[] = []; - _.forEach(data, (value: any) => { - ret.push(new (this.getContract())(value, this.model)); - }); - resolve(ret); - }, reject); - }); - } - - /** - * This feeds the data contract into the system - */ - protected abstract getContract(): new( - instance: any, - model: sequelize.Model - ) => T; -} diff --git a/node/connect.spec.ts b/node/connect.spec.ts new file mode 100644 index 0000000..857152b --- /dev/null +++ b/node/connect.spec.ts @@ -0,0 +1,33 @@ +import * as mockery from 'mockery'; +import * as chai from 'chai'; + +import {Sequelize} from '../mocks/Sequelize'; + +describe('node/connect', function() { + let connect: any; + + before(function(){ + mockery.enable(); + mockery.registerMock('sequelize', Sequelize); + mockery.registerAllowables([ + './connect', + 'lodash' + ]); + + connect = require('./connect'); + }); + it('connects to the database', function(){ + const options = { + 'c': 'd' + }; + connect.connect('a', 'b', 'c', options); + chai.expect(connect.connection.database).to.equal('a'); + chai.expect(connect.connection.username).to.equal('b'); + chai.expect(connect.connection.password).to.equal('c'); + chai.expect(connect.connection.options).to.equal(options); + }); + after(function() { + mockery.deregisterAll(); + mockery.disable(); + }); +}); diff --git a/node/field.ts b/node/field.ts index 7886113..d9a786f 100644 --- a/node/field.ts +++ b/node/field.ts @@ -1,3 +1,6 @@ +// DO NOT REMOVE THIS IMPORT it is required for this file to function +// tslint:disable-next-line:no-unused-variable +import * as reflectMetadata from 'reflect-metadata'; import * as moment from 'moment'; import {field as sharedField} from '../shared/field'; @@ -28,7 +31,11 @@ export function field(type?: Types): (target: any, key: string) => any { if (this.instance) { switch (type) { case(Types.dateTimeTz): - this.instance.set(key, newVal.toISOString()); + if (!moment.isMoment(newVal) || !( newVal).isValid()) { + this.instance.set(key, null); + } else { + this.instance.set(key, newVal.toISOString()); + } break; default: this.instance.set(key, newVal); diff --git a/node/getInject.spec.ts b/node/getInject.spec.ts new file mode 100644 index 0000000..bfc03a7 --- /dev/null +++ b/node/getInject.spec.ts @@ -0,0 +1,27 @@ +import * as mockery from 'mockery'; +import * as chai from 'chai'; + +describe('node/DataContract', function() { + let getInject: any; + + before(function(){ + mockery.enable(); + mockery.registerMock('../shared/DataObject', { + registeredClasses: new Map( [['a', 'a'], ['b', 'b']]) + }); + mockery.registerAllowables([ + './getInject' + ]); + + getInject = require('./getInject'); + }); + + it('returns injectables', function() { + chai.expect(getInject.getInject()).to.deep.equal(['a', 'b']); + }); + + after(function() { + mockery.deregisterAll(); + mockery.disable(); + }); +}); diff --git a/node/getInject.ts b/node/getInject.ts new file mode 100644 index 0000000..405a960 --- /dev/null +++ b/node/getInject.ts @@ -0,0 +1,5 @@ +import { registeredClasses, IDataConnection, IDataContract } from '../shared/DataObject'; + +export function getInject(): (new (...b: any[]) => IDataConnection)[] { + return Array.from(registeredClasses.values()); +} diff --git a/node/http.spec.ts b/node/http.spec.ts new file mode 100644 index 0000000..7be3744 --- /dev/null +++ b/node/http.spec.ts @@ -0,0 +1,365 @@ +import * as mockery from 'mockery'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import * as mockConnect from '../mocks/connect'; + +describe('node/http', function() { + let http: any; + let connect: any; + let object: any; + const responseData: any = { + setHeader: sinon.stub(), + statusCode: 0 + }; + + before(function() { + mockery.enable(); + mockery.registerAllowables([ + './http', + './DataConnection', + './DataConnection.spec.class', + './DataContract.spec.class', + '../shared/DataObject', + './connect' + ]); + + connect = require('./connect'); + + connect.connect('a', 'b', 'c'); + + require('./DataConnection.spec.class'); + http = require('./http'); + }); + + beforeEach(function () { + mockConnect.reset(connect); + + object = { + destroy: sinon.stub(), + get: sinon.stub(), + save: sinon.stub(), + set: sinon.stub() + }; + + object.destroy.returns(Promise.resolve()); + object.save.returns(Promise.resolve()); + object.get.returns(43); + + mockConnect.model.findById.withArgs(43).returns(Promise.resolve(object)); + mockConnect.model.create.returns(Promise.resolve(object)); + }); + + describe('get', function () { + const getRequest = { + method: 'GET', + url: '' + }; + + beforeEach(function () { + getRequest.url = '/orm/'; + }); + + it('errors when given a bad class', function () { + getRequest.url += 'badClass/0'; + + const response = http.HTTP.handle(getRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(404); + }), + chai.expect(response).to.eventually.equal('Resource Not Found') + ]); + }); + + it('disallows string ids', function () { + getRequest.url += 'badClass/sldkfasdgf'; + + const response = http.HTTP.handle(getRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(405); + }), + chai.expect(response).to.eventually.equal('Method Not Allowed') + ]); + }); + + it('disallows classless access', function () { + getRequest.url = '/orm'; + const response = http.HTTP.handle(getRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(405); + }), + chai.expect(response).to.eventually.equal('Method Not Allowed') + ]); + }); + + it('returns 404 for missing ids', function () { + getRequest.url += 'test.NoProp/1'; + const response = http.HTTP.handle(getRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(404); + }), + chai.expect(response).to.eventually.equal('Resource Not Found') + ]); + }); + + it('returns 200 with data for working ids', function () { + const parse = (data: string) => JSON.parse(data); + getRequest.url += 'test.NoProp/43'; + const response = http.HTTP.handle(getRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(200); + }), + chai.expect(response.then(parse)).to.eventually.have.property('id', 43), + chai.expect(response.then(parse)).to.eventually.have.property('createdAt'), + chai.expect(response.then(parse)).to.eventually.have.property('updatedAt') + ]); + }); + }); + + describe('put', function () { + const putRequest = { + method: 'PUT', + on: sinon.stub(), + url: '' + }; + + beforeEach(function () { + putRequest.url = '/orm/'; + + putRequest.on.withArgs('data').callsArgWith(1, '{}'); + putRequest.on.withArgs('end').callsArg(1); + }); + + it('errors when given a bad class', function () { + putRequest.url += 'badClass/0'; + + const response = http.HTTP.handle(putRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(404); + }), + chai.expect(response).to.eventually.equal('Resource Not Found') + ]); + }); + + it('returns 404 for missing ids', function () { + putRequest.url += 'test.NoProp/1'; + const response = http.HTTP.handle(putRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(404); + }), + chai.expect(response).to.eventually.equal('Resource Not Found') + ]); + }); + + it('returns 200 for working ids and updates the data (existing)', function () { + const parse = (data: string) => JSON.parse(data); + putRequest.url += 'test.StringProp/43'; + putRequest.on.withArgs('data').callsArgWith(1, '{"stringy":"asdasd"}'); + const response = http.HTTP.handle(putRequest, responseData); + return Promise.all([ + response.then(() => chai.expect(responseData.statusCode).to.equal(200)), + chai.expect(response.then(parse)).to.eventually.have.property('id', 43), + chai.expect(response.then(parse)).to.eventually.have.property('createdAt'), + chai.expect(response.then(parse)).to.eventually.have.property('updatedAt'), + response.then(() => chai.expect(object.save.called).to.be.true), + response.then(() => + chai.expect(object.set.getCall(0).args).to.deep.equal(['stringy', 'asdasd']) + ) + ]); + }); + + it('returns 200 for working ids and updates the data (new)', function () { + const parse = (data: string) => JSON.parse(data); + putRequest.url += 'test.StringProp'; + putRequest.on.withArgs('data').callsArgWith(1, '{"stringy":"asdasd"}'); + const response = http.HTTP.handle(putRequest, responseData); + return Promise.all([ + response.then(() => chai.expect(responseData.statusCode).to.equal(200)), + chai.expect(response.then(parse)).to.eventually.have.property('id', 43), + chai.expect(response.then(parse)).to.eventually.have.property('createdAt'), + chai.expect(response.then(parse)).to.eventually.have.property('updatedAt'), + response.then(() => chai.expect(mockConnect.model.create.called).to.be.true), + response.then(() => + chai.expect(mockConnect.model.create.getCall(0).args).to.deep.equal([{'stringy': 'asdasd'}]) + ) + ]); + }); + + it('errors on bad json', function () { + putRequest.url += 'test.StringProp/43'; + putRequest.on.withArgs('data').callsArgWith(1, '{'); + const response = http.HTTP.handle(putRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(412); + }), + chai.expect(response).to.eventually.equal('"Malformed JSON Details: Unexpected end of input"') + ]); + }); + + it('errors on failed save', function () { + putRequest.url += 'test.StringProp/43'; + object.save.returns(Promise.reject('')); + const response = http.HTTP.handle(putRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(412); + }), + chai.expect(response).to.eventually.equal('"Save Failed"') + ]); + }); + + it('explodes on exceptions', function () { + putRequest.url += 'test.StringProp/43'; + delete object.save; + const response = http.HTTP.handle(putRequest, responseData); + return Promise.all([ + chai.expect(response).to.eventually.be.rejected + ]); + }); + }); + + describe('post', function () { + const postRequest = { + method: 'POST', + on: sinon.stub(), + url: '' + }; + + beforeEach(function () { + postRequest.url = '/orm/'; + + postRequest.on.withArgs('data').callsArgWith(1, '{}'); + postRequest.on.withArgs('end').callsArg(1); + }); + + it('searches', function () { + postRequest.url += 'test.StringProp'; + postRequest.on.withArgs('data').callsArgWith(1, '{"stringy":"asdasd"}'); + const response = http.HTTP.handle(postRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(200); + }), + chai.expect(response).to.eventually.equal('[]'), + response.then(() => chai.expect(mockConnect.model.findAll.called).to.be.true), + response.then(() => + chai.expect(mockConnect.model.findAll.getCall(0).args).to.deep.equal([{ + "include": [ + { + "all": true + } + ], + "where": { + "stringy": "asdasd" + } + }]) + ) + ]); + }); + + it('errors when given a bad class', function () { + postRequest.url += 'badClass'; + + const response = http.HTTP.handle(postRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(404); + }), + chai.expect(response).to.eventually.equal('Resource Not Found') + ]); + }); + + it('errors on bad json', function () { + postRequest.url += 'test.StringProp'; + postRequest.on.withArgs('data').callsArgWith(1, '{'); + const response = http.HTTP.handle(postRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(412); + }), + chai.expect(response).to.eventually.equal('"Malformed JSON Details: Unexpected end of input"') + ]); + }); + + it('explodes on exceptions', function () { + postRequest.url += 'test.StringProp'; + delete mockConnect.model.findAll; + const response = http.HTTP.handle(postRequest, responseData); + return Promise.all([ + chai.expect(response).to.eventually.be.rejected + ]); + }); + }); + + describe('delete', function () { + const deleteRequest = { + method: 'DELETE', + url: '' + }; + + beforeEach(function () { + deleteRequest.url = '/orm/'; + }); + + it('errors when given a bad class', function () { + deleteRequest.url += 'badClass/0'; + + const response = http.HTTP.handle(deleteRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(404); + }), + chai.expect(response).to.eventually.equal('Resource Not Found') + ]); + }); + + it('returns 404 for missing ids', function () { + deleteRequest.url += 'test.NoProp/1'; + const response = http.HTTP.handle(deleteRequest, responseData); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(404); + }), + chai.expect(response).to.eventually.equal('"Resource Not Found"') + ]); + }); + + it('returns 200 for working ids', function () { + deleteRequest.url += 'test.NoProp/43'; + const response = http.HTTP.handle(deleteRequest, responseData).then( + (data: string) => JSON.parse(data) + ); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(200); + }) + ]); + }); + + it('handles failure', function () { + deleteRequest.url += 'test.NoProp/43'; + object.destroy.returns(Promise.reject('')); + const response = http.HTTP.handle(deleteRequest, responseData).then( + (data: string) => JSON.parse(data) + ); + return Promise.all([ + response.then(() => { + chai.expect(responseData.statusCode).to.equal(412); + }), + chai.expect(response).to.eventually.equal('Delete Failed') + ]); + }); + }); + + after(function() { + mockery.deregisterAll(); + mockery.disable(); + }); +}); diff --git a/node/http.ts b/node/http.ts index fddb95c..e58a7af 100644 --- a/node/http.ts +++ b/node/http.ts @@ -1,6 +1,7 @@ import * as http from 'http'; -import { DataConnection, DataContract } from './DataObject'; +import { DataContract } from './DataContract'; +import { DataConnection } from './DataConnection'; import { registeredClasses } from '../shared/DataObject'; export class HTTP { @@ -31,13 +32,13 @@ export class HTTP { contract = HTTP.initialized[idx]; } - if (requestData.method === 'GET' && id) { + if (requestData.method === 'GET' && id !== undefined) { if (contract) { return HTTP.GET(id, requestData, responseData, contract); } else { return HTTP.respondNotFound(responseData); } - } else if (requestData.method === 'POST' && !id) { + } else if (requestData.method === 'POST' && id === undefined) { if (contract) { return HTTP.POST(requestData, responseData, contract); } else { @@ -49,7 +50,7 @@ export class HTTP { } else { return HTTP.respondNotFound(responseData); } - } else if (requestData.method === 'DELETE' && id) { + } else if (requestData.method === 'DELETE' && id !== undefined) { if (contract) { return HTTP.DELETE(id, requestData, responseData, contract); } else { @@ -107,7 +108,7 @@ export class HTTP { contract: DataConnection ): Promise { let dataPromise: Promise; - if (id) { + if (id !== undefined) { dataPromise = contract.fetch(id); } else { dataPromise = new Promise((resolve) => { diff --git a/node/index.ts b/node/index.ts index fa12695..8ed8c45 100644 --- a/node/index.ts +++ b/node/index.ts @@ -1,4 +1,6 @@ export {field} from './field'; export {connect} from './connect'; -export {DataContract, DataConnection} from './DataObject'; +export {DataContract} from './DataContract'; +export {DataConnection} from './DataConnection'; export {register} from '../shared/DataObject'; +export {HTTP} from './http'; diff --git a/package.json b/package.json index 68fd30f..0dff812 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,11 @@ "main": "src/index", "scripts": { "lint": "tslint -e \"node_modules/**\" -e \"typings/**\" -e \"**/*.d.ts\" \"**/*.ts\"", + "mocha": "./run_mocha.sh", + "coverage": "istanbul check-coverage", + "coveralls": "cat coverage/lcov.info | ./node_modules/.bin/coveralls", "prepublish": "typings install && tsc", - "test": "npm run lint" + "test": "npm run lint && npm run mocha && npm run coveralls && npm run coverage" }, "repository": { "type": "git", @@ -24,7 +27,17 @@ "@angular/core": "^2.0.0-rc.4", "@angular/http": "^2.0.0-rc.4", "@angular/platform-browser": "^2.0.0-rc.4", + "chai": "^3.5.0", + "chai-as-promised": "^5.3.0", + "coveralls": "^2.11.9", + "istanbul": "^1.1.0-alpha.1", + "livereload": "^0.4.1", + "mocha": "^2.5.3", + "mockery": "^1.7.0", + "nodemon": "^1.9.2", "rxjs": "^5.0.0-beta.6", + "sinon": "^1.17.4", + "source-map-support": "^0.4.1", "tslint": "^3.12.1", "typescript": "^1.8.10", "typings": "^1.3.1", @@ -35,6 +48,7 @@ "typings": "^1.3.1" }, "dependencies": { + "browser-sync": "^2.13.0", "sequelize": "^3.23.4", "bunyan": "^1.8.1", "es6-shim": "^0.35.1", diff --git a/run_mocha.sh b/run_mocha.sh new file mode 100755 index 0000000..b21ccd9 --- /dev/null +++ b/run_mocha.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +./node_modules/.bin/istanbul cover \ + -i './{,!(node_modules|coverage|webpack|mocks)/**/}/{,!(index|mochaInit|AuthHandler)*}.js' \ + ./node_modules/.bin/_mocha \ + -r node_modules/reflect-metadata \ + -r ./mochaInit \ + './{,!(node_modules)/**/}/*.spec.js' \ No newline at end of file diff --git a/shared/DataObject.ts b/shared/DataObject.ts index 5de34f2..a8aab7d 100644 --- a/shared/DataObject.ts +++ b/shared/DataObject.ts @@ -5,7 +5,7 @@ export interface IDataContract { createdAt: moment.Moment; updatedAt: moment.Moment; - save(): Promise; + save(): Promise; delete(): Promise; } diff --git a/shared/field.ts b/shared/field.ts index 08fb2dd..0c88a95 100644 --- a/shared/field.ts +++ b/shared/field.ts @@ -33,13 +33,12 @@ export function field(target: any, key: string, actions: IActions, type?: Types) case 'Number': type = Types.float; break; + /* istanbul ignore next */ case 'Object': - // tslint:disable-next-line:no-console - console.error('Automatic mapping of Objects is unsupported'); - break; + throw new TypeError('Automatic mapping of Objects is unsupported'); + /* istanbul ignore next */ default: - // tslint:disable-next-line:no-console - console.error('Unknown js type found', jsType.name); + throw new TypeError('Unknown js type found! ' + jsType.name); } } Reflect.defineMetadata("ORM:type", type, target, key); diff --git a/typings.json b/typings.json index 16a55fb..42e2e9d 100644 --- a/typings.json +++ b/typings.json @@ -7,5 +7,14 @@ "dependencies": { "lodash": "registry:npm/lodash#4.0.0+20160416211519", "sequelize": "registry:npm/sequelize#3.0.0+20160604152601" + }, + "devDependencies": { + "chai": "registry:npm/chai#3.5.0+20160415060238", + "chai-as-promised": "registry:npm/chai-as-promised#5.1.0+20160310030142", + "sinon": "registry:npm/sinon#1.16.0+20160427193336" + }, + "globalDevDependencies": { + "mocha": "registry:env/mocha#2.2.5+20160321223601", + "mockery": "registry:dt/mockery#1.4.0+20160316155526" } } diff --git a/watch_coverage.sh b/watch_coverage.sh new file mode 100755 index 0000000..2def40f --- /dev/null +++ b/watch_coverage.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +./node_modules/.bin/nodemon -i coverage -x ./run_mocha.sh & +nodemon=$! +sleep 5s +./node_modules/.bin/browser-sync \ + start \ + --no-inject-changes \ + --logLevel info \ + --no-open \ + -f 'coverage/lcov-report' \ + -s --ss 'coverage/lcov-report' | grep -v "File changed" & +browsersync=$! + +wait $nodemon +wait $browsersync \ No newline at end of file