diff --git a/.eslintignore b/.eslintignore index 7fe2eaa4..8069bae0 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,16 +1,18 @@ # compiled output -dist/* +coverage/ +dist/ +.nyc_output/ # tests -test/* +test/ # docs -docs/* +docs/ # dependencies -node_modules/* +node_modules/ # misc -decl/* -examples/* +decl/ +examples/ *interfaces.js diff --git a/.eslintrc.json b/.eslintrc.json index 5de8bec5..99d68dc8 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,19 +3,19 @@ "plugins": ["flowtype"], "extends": "eslint:recommended", "env": { + "es6": true, "node": true }, + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "impliedStrict": true + } + }, "globals": { - "T": true, - "Map": true, - "Set": true, "Class": true, - "Proxy": true, - "Promise": true, - "Reflect": true, - "WeakMap": true, - "WeakSet": true, - "Iterable": true, + "Generator": true, "$PropertyType": true }, "settings": { diff --git a/.flowconfig b/.flowconfig index cc3e836d..6d28fce5 100644 --- a/.flowconfig +++ b/.flowconfig @@ -5,13 +5,15 @@ decl .*/lib/.* .*/dist/.* .*/docs/.* -.*/test/.* .*/scripts/.* -.*/examples/.* -.*/node_modules/.* +.*/node_modules/bcryptjs/* [options] -esproposal.class_instance_fields=enable -esproposal.class_static_fields=enable +suppress_comment=\\(.\\|\n\\)*\\$FlowIgnore + +module.name_mapper='LUX_LOCAL' -> '' unsafe.enable_getters_and_setters=true + +esproposal.class_static_fields=enable +esproposal.class_instance_fields=enable diff --git a/.gitignore b/.gitignore index b4e07c74..e65c2607 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ # See http://help.github.com/ignore-files/ for more about ignoring files. # compiled output +*.lcov +coverage/ dist/ docs/ +.nyc_output/ # dependencies node_modules/ diff --git a/.npmignore b/.npmignore index 4bf9a1c4..8b27fd10 100644 --- a/.npmignore +++ b/.npmignore @@ -1,17 +1,20 @@ # dependencies -/node_modules +node_modules/ # misc .git *.DS_Store -/examples +*.lcov +.nyc_output/ +coverage/ +examples/ # docs -/docs +docs/ # logs -/log +log/ npm-debug.log # tests -/test +test/ diff --git a/.travis.yml b/.travis.yml index ad90ca7e..35f5b6dc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -44,5 +44,8 @@ before_script: install: - bash -e scripts/travis/install.sh +after_success: + - npm run test:codecov + notifications: email: false diff --git a/README.md b/README.md index e56f1a06..4dd06ecd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Lux -[![Build Status](https://travis-ci.org/postlight/lux.svg?branch=master)](https://travis-ci.org/postlight/lux) [![Build status](https://ci.appveyor.com/api/projects/status/xxwunscfe3rsxdmr/branch/master?svg=true)](https://ci.appveyor.com/project/zacharygolba/lux/branch/master) +[![Build Status](https://travis-ci.org/postlight/lux.svg?branch=master)](https://travis-ci.org/postlight/lux) [![Build status](https://ci.appveyor.com/api/projects/status/xxwunscfe3rsxdmr/branch/master?svg=true)](https://ci.appveyor.com/project/zacharygolba/lux/branch/master) [![codecov](https://codecov.io/gh/postlight/lux/branch/master/graph/badge.svg)](https://codecov.io/gh/postlight/lux) [![Dependency Status](https://david-dm.org/postlight/lux.svg)](https://david-dm.org/postlight/lux) [![npm version](https://badge.fury.io/js/lux-framework.svg)](https://badge.fury.io/js/lux-framework) [![Join the chat at https://gitter.im/postlight/lux](https://badges.gitter.im/postlight/lux.svg)](https://gitter.im/postlight/lux?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) A MVC style framework for building highly performant, large scale JSON APIs that anybody who knows the JavaScript language and its modern features will understand. diff --git a/decl/chai.js b/decl/chai.js new file mode 100644 index 00000000..e4c14195 --- /dev/null +++ b/decl/chai.js @@ -0,0 +1,115 @@ +//@flow +// src: github.com/flowtype/flow-typed/tree/master/definitions/npm/chai_v3.5.x +declare module 'chai' { + declare type ExpectChain = { + and: ExpectChain; + at: ExpectChain; + be: ExpectChain; + been: ExpectChain; + have: ExpectChain; + has: ExpectChain; + is: ExpectChain; + of: ExpectChain; + same: ExpectChain; + that: ExpectChain; + to: ExpectChain; + which: ExpectChain; + with: ExpectChain; + + not: ExpectChain; + deep: ExpectChain; + any: ExpectChain; + all: ExpectChain; + + a: ExpectChain & (type: string) => ExpectChain; + an: ExpectChain & (type: string) => ExpectChain; + + frozen: ExpectChain & (value: mixed) => ExpectChain; + sealed: ExpectChain & (value: mixed) => ExpectChain; + include: ExpectChain & (value: mixed) => ExpectChain; + includes: ExpectChain & (value: mixed) => ExpectChain; + contain: ExpectChain & (value: mixed) => ExpectChain; + contains: ExpectChain & (value: mixed) => ExpectChain; + + eql: (value: T) => ExpectChain; + equal: (value: T) => ExpectChain; + equals: (value: T) => ExpectChain; + + above: (value: T & number) => ExpectChain; + least: (value: T & number) => ExpectChain; + below: (value: T & number) => ExpectChain; + most: (value: T & number) => ExpectChain; + within: (start: T & number, finish: T & number) => ExpectChain; + + instanceof: (constructor: mixed) => ExpectChain; + + property: ( +

(name: string, value?: P) => ExpectChain

+ & (name: string) => ExpectChain + ); + + length: ExpectChain; + lengthOf: (value: number) => ExpectChain; + + match: (regex: RegExp) => ExpectChain; + string: (string: string) => ExpectChain; + + key: (key: string) => ExpectChain; + + keys: ( + key: string | Array, + ...keys: Array + ) => ExpectChain; + + throw: ( + err: Class | Error | RegExp | string, + msg?: RegExp | string + ) => ExpectChain; + + respondTo: (method: string) => ExpectChain; + itself: ExpectChain; + + satisfy: (method: (value: T) => bool) => ExpectChain; + + closeTo: (expected: T & number, delta: number) => ExpectChain; + + members: (set: mixed) => ExpectChain; + oneOf: (list: Array) => ExpectChain; + + change: (obj: mixed, key: string) => ExpectChain; + increase: (obj: mixed, key: string) => ExpectChain; + decrease: (obj: mixed, key: string) => ExpectChain; + + // dirty-chai + ok: () => ExpectChain; + true: () => ExpectChain; + false: () => ExpectChain; + null: () => ExpectChain; + undefined: () => ExpectChain; + exist: () => ExpectChain; + empty: () => ExpectChain; + + // chai-immutable + size: (n: number) => ExpectChain; + + // sinon-chai + called: () => ExpectChain; + callCount: (n: number) => ExpectChain; + calledOnce: () => ExpectChain; + calledBefore: (spy: mixed) => ExpectChain; + calledAfter: (spy: mixed) => ExpectChain; + calledWith: (...args: Array) => ExpectChain; + calledWithMatch: (...args: Array) => ExpectChain; + calledWithExactly: (...args: Array) => ExpectChain; + }; + + declare function expect(actual: T): ExpectChain; + + declare function use(plugin: (chai: Object, utils: Object) => void): void; + + declare var config: { + includeStack: boolean; + showDiff: boolean; + truncateThreshold: boolean; + }; +} diff --git a/decl/mocha.js b/decl/mocha.js new file mode 100644 index 00000000..a1eb5d15 --- /dev/null +++ b/decl/mocha.js @@ -0,0 +1,26 @@ +// @flow +// src: github.com/flowtype/flow-typed/tree/master/definitions/npm/mocha_v2.4.x +type $npm$mocha$testFunction = (done: () => void) => void | Promise; + +declare module 'mocha' { + declare var describe: { + (name: string, spec: () => void): void; + only(description: string, spec: () => void): void; + skip(description: string, spec: () => void): void; + timeout(ms: number): void; + }; + + declare var context: typeof describe; + + declare var it: { + (name: string, spec?: $npm$mocha$testFunction): void; + only(description: string, spec: $npm$mocha$testFunction): void; + skip(description: string, spec: $npm$mocha$testFunction): void; + timeout(ms: number): void; + }; + + declare function before(method: $npm$mocha$testFunction): void; + declare function beforeEach(method: $npm$mocha$testFunction): void; + declare function after(method: $npm$mocha$testFunction): void; + declare function afterEach(method: $npm$mocha$testFunction): void; +} diff --git a/decl/node-fetch.js b/decl/node-fetch.js new file mode 100644 index 00000000..b6da5e5b --- /dev/null +++ b/decl/node-fetch.js @@ -0,0 +1,56 @@ +// @flow +declare module 'node-fetch' { + declare var exports: (url: string, options?: { + body?: any; + referrer?: string; + integrity?: string; + + mode?: + | 'cors' + | 'no-cors' + | 'same-origin'; + + cache?: + | 'default' + | 'no-store' + | 'reload' + | 'no-cache' + | 'force-cache' + | 'only-if-cached'; + + method?: + | 'GET' + | 'HEAD' + | 'OPTIONS' + | 'PATCH' + | 'POST' + | 'DELETE'; + + headers?: { + [key: string]: string; + }; + + redirect?: + | 'follow' + | 'manual' + | 'error'; + + credentials?: + | 'omit' + | 'include' + | 'same-origin'; + + referrerPolicy?: + | 'no-referrer' + | 'no-referrer-when-downgrade' + | 'origin' + | 'origin-when-cross-origin' + | 'unsafe-url'; + }) => Promise<{ + status: number; + headers: Map; + + text(): Promise; + json(): Promise; + }>; +} diff --git a/decl/sinon.js b/decl/sinon.js new file mode 100644 index 00000000..a9df2dcd --- /dev/null +++ b/decl/sinon.js @@ -0,0 +1,11 @@ +// @flow + +declare module 'sinon' { + declare type Spy = { + calledWith: (...args: Array) => boolean; + calledOnce: boolean; + restore: () => void; + reset: () => void; + }; + declare function spy(module: Object, method: string): Spy; +} diff --git a/lib/babel-hook.js b/lib/babel-hook.js index f494fa24..812ae7d0 100644 --- a/lib/babel-hook.js +++ b/lib/babel-hook.js @@ -1,20 +1,39 @@ 'use strict'; -// Require this module to use code in the /src dir prior to transpilation. +/*************************************************************************** + * Require this module to use code in the /src dir prior to transpilation. * + ***************************************************************************/ + +const plugins = (...items) => items.concat([ + 'syntax-flow', + 'syntax-trailing-function-commas', + 'transform-async-to-generator', + 'transform-class-properties', + 'transform-es2015-destructuring', + 'transform-es2015-parameters', + 'transform-es2015-spread', + 'transform-exponentiation-operator', + 'transform-flow-strip-types', + 'transform-object-rest-spread', + 'transform-es2015-modules-commonjs' +]); + require('babel-register')({ babelrc: false, - - plugins: [ - 'syntax-flow', - 'syntax-trailing-function-commas', - 'transform-async-to-generator', - 'transform-class-properties', - 'transform-es2015-destructuring', - 'transform-es2015-parameters', - 'transform-es2015-spread', - 'transform-exponentiation-operator', - 'transform-flow-strip-types', - 'transform-object-rest-spread', - 'transform-es2015-modules-commonjs' - ] + plugins: plugins(), + env: { + test: { + sourceMaps: 'inline', + plugins: plugins(['istanbul', { + include: [ + 'src/**/*.js' + ], + exclude: [ + '**/test', + '**/errors', + '**/interfaces.js' + ] + }]) + } + } }); diff --git a/package.json b/package.json index f174d722..367f11c9 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "flow": "node scripts/flow.js", "lint": "eslint .", "start": "lux serve", - "test": "npm run build && npm run build:test && mocha --timeout 60000 test/dist/index.js" + "test": "npm run build && nyc -i ./lib/babel-hook.js --instrument false --source-map false mocha -r ./lib/babel-hook.js test/index.js src/**/*.test.js", + "test:codecov": "nyc report --reporter=lcov > coverage.lcov && codecov -t $LUX_CODECOV_TOKEN" }, "author": "Zachary Golba", "license": "MIT", @@ -52,14 +53,17 @@ }, "devDependencies": { "babel-core": "6.14.0", + "babel-plugin-istanbul": "2.0.1", "babel-plugin-transform-es2015-modules-commonjs": "6.14.0", "babel-preset-lux": "1.2.0", "chai": "3.5.0", + "codecov": "1.0.1", "documentation": "4.0.0-beta9", "eslint-plugin-flowtype": "2.18.2", "flow-bin": "0.32.0", - "isomorphic-fetch": "2.2.1", "mocha": "3.0.2", - "rollup-plugin-multi-entry": "2.0.1" + "node-fetch": "1.6.1", + "nyc": "8.3.0", + "sinon": "1.17.5" } } diff --git a/scripts/appveyor/before-test.ps1 b/scripts/appveyor/before-test.ps1 index ba7f604a..38406ce8 100644 --- a/scripts/appveyor/before-test.ps1 +++ b/scripts/appveyor/before-test.ps1 @@ -15,3 +15,5 @@ Switch ($env:DATABASE_DRIVER) { Write-Host $null >> C:\projects\lux\test\test-app\db\lux_test_test.sqlite } } + +New-Item -ItemType directory C:\tmp diff --git a/scripts/build/test.js b/scripts/build/test.js deleted file mode 100644 index 96765e67..00000000 --- a/scripts/build/test.js +++ /dev/null @@ -1,39 +0,0 @@ -'use strict'; - -const path = require('path'); -const rollup = require('rollup').rollup; - -// Plugins -const babel = require('rollup-plugin-babel'); -const multiEntry = require('rollup-plugin-multi-entry'); -const nodeResolve = require('rollup-plugin-node-resolve'); - -let config = require('./config'); - -config = { - rollup: Object.assign({}, config.rollup, { - entry: [ - path.join(__dirname, '..', '..', 'test', 'index.js'), - path.join(__dirname, '..', '..', 'test', 'unit', '**', '*.js'), - path.join(__dirname, '..', '..', 'test', 'integration', '**', '*.js') - ], - - plugins: [ - babel(), - multiEntry(), - nodeResolve({ preferBuiltins: true }) - ] - }), - - bundle: Object.assign({}, config.bundle, { - dest: path.join(__dirname, '..', '..', 'test', 'dist', 'index.js') - }) -}; - -rollup(config.rollup) - .then(bundle => bundle.write(config.bundle)) - .then(() => process.exit(0)) - .catch(err => { - console.error(err); - process.exit(1); - }); diff --git a/scripts/clean.js b/scripts/clean.js index 078d1129..4c2dc11d 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -6,9 +6,11 @@ const path = require('path'); const rmrf = require('../src/packages/fs').rmrf; Promise.all([ + rmrf(path.join(__dirname, '..', '.nyc_output')), + rmrf(path.join(__dirname, '..', 'coverage')), + rmrf(path.join(__dirname, '..', 'coverage.lcov')), rmrf(path.join(__dirname, '..', 'dist')), rmrf(path.join(__dirname, '..', 'docs')), - rmrf(path.join(__dirname, '..', 'test', 'dist')), rmrf(path.join(__dirname, '..', 'test', 'test-app', 'dist')) ]).then(() => { process.exit(0); diff --git a/src/packages/config/test/config.test.js b/src/packages/config/test/config.test.js new file mode 100644 index 00000000..7858d1f3 --- /dev/null +++ b/src/packages/config/test/config.test.js @@ -0,0 +1,17 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import { CREATE_DEFAULT_CONFIG_RESULT } from './fixtures/results'; + +import { createDefaultConfig } from '../index'; + +describe('module "config"', () => { + describe('#createDefaultConfig()', () => { + it('creates a default config object in the context of NODE_ENV', () => { + const result = createDefaultConfig(); + + expect(result).to.deep.equal(CREATE_DEFAULT_CONFIG_RESULT); + }); + }); +}); diff --git a/src/packages/config/test/fixtures/results.js b/src/packages/config/test/fixtures/results.js new file mode 100644 index 00000000..4a3d2878 --- /dev/null +++ b/src/packages/config/test/fixtures/results.js @@ -0,0 +1,22 @@ +// @flow +import { NODE_ENV } from '../../../../constants'; + +const isTestENV = NODE_ENV === 'test'; +const isProdENV = NODE_ENV === 'production'; + +export const CREATE_DEFAULT_CONFIG_RESULT = { + server: { + cors: { + enabled: false + } + }, + logging: { + level: isProdENV ? 'INFO' : 'DEBUG', + format: isProdENV ? 'json' : 'text', + enabled: !isTestENV, + + filter: { + params: [] + } + } +}; diff --git a/src/packages/controller/index.js b/src/packages/controller/index.js index e89a7c82..8bfa38f0 100644 --- a/src/packages/controller/index.js +++ b/src/packages/controller/index.js @@ -8,6 +8,7 @@ import findMany from './utils/find-many'; import findRelated from './utils/find-related'; import type Serializer from '../serializer'; +import type { Query } from '../database'; import type { Request, Response } from '../server'; import type { Controller$opts, Controller$Middleware } from './interfaces'; @@ -278,7 +279,7 @@ class Controller { * @param {Request} request * @param {Response} response */ - index(req: Request) { + index(req: Request): Query> { return findMany(req); } @@ -292,7 +293,7 @@ class Controller { * @param {Request} request * @param {Response} response */ - show(req: Request) { + show(req: Request): Query { return findOne(req); } @@ -303,43 +304,57 @@ class Controller { * @param {Request} request * @param {Response} response */ - async create(req: Request, res: Response): Promise { + create(req: Request, res: Response): Promise { const { - url: { - pathname - }, - params: { data: { - attributes, - relationships - } = {} + attributes + } }, - route: { controller: { - model, - controllers + model } } } = req; - const record = await model.create(attributes); - const id = Reflect.get(record, model.primaryKey); - - if (relationships) { - Object.assign( - record, - await findRelated(controllers, relationships) - ); - - await record.save(true); - } + return model + .create(attributes) + .then(record => { + const { + params: { + data: { + relationships + } + }, + route: { + controller: { + controllers + } + } + } = req; + + if (relationships) { + return findRelated( + controllers, + relationships + ).then(related => { + Object.assign(record, related); + return record.save(true); + }); + } else { + return record; + } + }) + .then(record => { + const { url: { pathname } } = req; + const id = record.getPrimaryKey(); - res.statusCode = 201; - res.setHeader('Location', `${getDomain(req) + pathname}/${id}`); + res.statusCode = 201; + res.setHeader('Location', `${getDomain(req) + pathname}/${id}`); - return record; + return record; + }); } /** @@ -349,44 +364,39 @@ class Controller { * @param {Request} request * @param {Response} response */ - async update(req: Request): Promise { - const record = await findOne(req); - - const { - params: { - data: { - attributes, - relationships - } = {} - }, - - route: { - controller: { - controllers + update(req: Request): Promise { + return findOne(req).then(record => { + const { + params: { + data: { + attributes, + relationships + } } - } - } = req; + } = req; - if (record) { Object.assign(record, attributes); if (relationships) { - Object.assign( - record, - await findRelated(controllers, relationships) - ); - - return await record.save(true); - } else { - if (record.isDirty) { - return await record.save(); - } else { - return 204; - } + const { + route: { + controller: { + controllers + } + } + } = req; + + return findRelated( + controllers, + relationships + ).then(related => { + Object.assign(record, related); + return record.save(true); + }); } - } - return 404; + return record.isDirty ? record.save() : 204; + }); } /** @@ -396,13 +406,10 @@ class Controller { * @param {Request} request * @param {Response} response */ - async destroy(req: Request): Promise { - const record = await findOne(req); - - if (record) { - await record.destroy(); - return 204; - } + destroy(req: Request): Promise { + return findOne(req) + .then(record => record.destroy()) + .then(() => 204); } /** @@ -412,8 +419,8 @@ class Controller { * @param {Response} response * @private */ - preflight() { - return 204; + preflight(): Promise { + return Promise.resolve(204); } } diff --git a/src/packages/controller/test/controller.test.js b/src/packages/controller/test/controller.test.js new file mode 100644 index 00000000..bc158400 --- /dev/null +++ b/src/packages/controller/test/controller.test.js @@ -0,0 +1,562 @@ +// @flow +import { expect } from 'chai'; +import { it, describe, before, beforeEach, afterEach } from 'mocha'; + +import Controller from '../index'; +import Serializer from '../../serializer'; +import { Model } from '../../database'; + +import setType from '../../../utils/set-type'; +import { getTestApp } from '../../../../test/utils/get-test-app'; + +import type { Request, Response } from '../../server'; + +const HOST = 'localhost:4000'; + +describe('module "controller"', () => { + describe('class Controller', () => { + let Post: Class; + let subject: Controller; + + const attributes = [ + 'id', + 'body', + 'title', + 'isPublic', + 'createdAt', + 'updatedAt' + ]; + + const assertRecord = (item, keys = attributes) => { + expect(item).to.be.an.instanceof(Post); + + if (item instanceof Post) { + expect(item.rawColumnData).to.have.all.keys(keys); + } + }; + + before(async () => { + const app = await getTestApp(); + + Post = setType(() => app.models.get('post')); + + subject = new Controller({ + model: Post, + namespace: '', + serializer: new Serializer({ + model: Post, + parent: null, + namespace: '' + }) + }); + + subject.controllers = app.controllers; + }); + + describe('#index()', () => { + const createRequest = (params = {}): Request => setType(() => ({ + params, + route: { + controller: subject + }, + defaultParams: { + sort: 'createdAt', + filter: {}, + fields: { + posts: attributes + }, + page: { + size: 25, + number: 1 + } + } + })); + + it('returns an array of records', async () => { + const request = createRequest(); + const result = await subject.index(request); + + expect(result).to.be.an('array').with.lengthOf(25); + result.forEach(item => assertRecord(item)); + }); + + it('supports specifying page size', async () => { + const request = createRequest({ + page: { + size: 10 + } + }); + + const result = await subject.index(request); + + expect(result).to.be.an('array').with.lengthOf(10); + result.forEach(item => assertRecord(item)); + }); + + it('supports filter parameters', async () => { + const request = createRequest({ + filter: { + isPublic: false + } + }); + + const result = await subject.index(request); + + expect(result).to.be.an('array').with.length.above(0); + + result.forEach(item => { + assertRecord(item); + expect(Reflect.get(item, 'isPublic')).to.be.false; + }); + }); + + it('supports sparse field sets', async () => { + const request = createRequest({ + fields: { + posts: ['id', 'title'] + } + }); + + const result = await subject.index(request); + + expect(result).to.be.an('array').with.lengthOf(25); + result.forEach(item => assertRecord(item, ['id', 'title'])); + }); + + it('supports eager loading relationships', async () => { + const request = createRequest({ + include: ['user'], + fields: { + users: [ + 'id', + 'name', + 'email' + ] + } + }); + + const result = await subject.index(request); + + expect(result).to.be.an('array').with.lengthOf(25); + + result.forEach(item => { + assertRecord(item, [ + ...attributes, + 'user' + ]); + + expect(item.rawColumnData.user).to.have.all.keys([ + 'id', + 'name', + 'email' + ]); + }); + }); + }); + + describe('#show()', () => { + const createRequest = (params = {}): Request => setType(() => ({ + params, + route: { + controller: subject + }, + defaultParams: { + fields: { + posts: attributes + } + } + })); + + it('returns a single record', async () => { + const request = createRequest({ id: 1 }); + const result = await subject.show(request); + + expect(result).to.be.ok; + + if (result) { + assertRecord(result); + } + }); + + it('throws an error if the record is not found', async () => { + const request = createRequest({ id: 10000 }); + + await subject.show(request).catch(err => { + expect(err).to.be.an.instanceof(Error); + }); + }); + + it('supports sparse field sets', async () => { + const request = createRequest({ + id: 1, + fields: { + posts: ['id', 'title'] + } + }); + + const result = await subject.show(request); + + expect(result).to.be.ok; + assertRecord(result, ['id', 'title']); + }); + + it('supports eager loading relationships', async () => { + const request = createRequest({ + id: 1, + include: ['user'], + fields: { + users: [ + 'id', + 'name', + 'email' + ] + } + }); + + const result = await subject.show(request); + + expect(result).to.be.ok; + + if (result) { + assertRecord(result, [ + ...attributes, + 'user' + ]); + + expect(result.rawColumnData.user).to.have.all.keys([ + 'id', + 'name', + 'email' + ]); + } + }); + }); + + describe('#create()', () => { + let result: Model; + + const createRequest = (params = {}): Request => setType(() => ({ + params, + url: { + pathname: '/posts' + }, + route: { + controller: subject + }, + headers: new Map([ + ['host', HOST] + ]), + connection: { + encrypted: false + }, + defaultParams: { + fields: { + posts: attributes, + users: ['id'] + } + } + })); + + const createResponse = (): Response => setType(() => ({ + headers: new Map(), + statusCode: 200, + + setHeader(key: string, value: string): void { + this.headers.set(key, value); + }, + + getHeader(key: string): string | void { + return this.headers.get(key); + } + })); + + afterEach(async () => { + await result.destroy(); + }); + + it('returns the newly created record', async () => { + const response = createResponse(); + + const request = createRequest({ + include: ['user'], + data: { + type: 'posts', + attributes: { + title: '#create() Test', + isPublic: true + }, + relationships: { + user: { + data: { + id: 1, + type: 'posts' + } + } + } + }, + fields: { + users: ['id'] + } + }); + + result = await subject.create(request, response); + + assertRecord(result, [ + 'id', + 'title', + 'isPublic', + 'createdAt', + 'updatedAt' + ]); + + const user = await Reflect.get(result, 'user'); + const title = Reflect.get(result, 'title'); + const isPublic = Reflect.get(result, 'isPublic'); + + expect(user.id).to.equal(1); + expect(title).to.equal('#create() Test'); + expect(isPublic).to.equal(true); + }); + + it('sets `response.statusCode` to the number `201`', async () => { + const response = createResponse(); + + const request = createRequest({ + data: { + type: 'posts', + attributes: { + title: '#create() Test' + } + } + }); + + result = await subject.create(request, response); + + expect(response.statusCode).to.equal(201); + }); + + it('sets the correct `Location` header', async () => { + const response = createResponse(); + + const request = createRequest({ + data: { + type: 'posts', + attributes: { + title: '#create() Test' + } + } + }); + + result = await subject.create(request, response); + + const id = Reflect.get(result, 'id'); + const location = response.getHeader('Location'); + + expect(location).to.equal(`http://${HOST}/posts/${id}`); + }); + }); + + describe('#update()', () => { + let record: Model; + const createRequest = (params = {}): Request => setType(() => ({ + params, + route: { + controller: subject + }, + defaultParams: { + fields: { + posts: attributes + } + } + })); + + beforeEach(async () => { + record = await Post.create({ + title: '#destroy() Test' + }); + }); + + afterEach(async () => { + await record.destroy(); + }); + + it('returns a record if attribute(s) change', async () => { + let item = record; + let isPublic = Reflect.get(item, 'isPublic'); + const id = Reflect.get(item, 'id'); + + expect(isPublic).to.be.false; + + const request = createRequest({ + id, + type: 'posts', + data: { + attributes: { + isPublic: true + } + } + }); + + const result = await subject.update(request); + + assertRecord(result); + + item = await Post.find(id); + isPublic = Reflect.get(item, 'isPublic'); + + expect(isPublic).to.be.true; + }); + + it('returns a record if relationships(s) change', async () => { + let item = record; + let user = await Reflect.get(item, 'user'); + const id = Reflect.get(item, 'id'); + + expect(user).to.be.null; + + const request = createRequest({ + id, + type: 'posts', + include: ['user'], + data: { + relationships: { + user: { + data: { + id: 1, + type: 'users' + } + } + } + }, + fields: { + users: ['id'] + } + }); + + const result = await subject.update(request); + + assertRecord(result, [ + ...attributes, + 'user' + ]); + + item = await Post.find(id); + user = await Reflect.get(item, 'user'); + + expect(user.id).to.equal(1); + }); + + it('returns the number `204` if no changes occur', async () => { + const id = Reflect.get(record, 'id'); + + const request = createRequest({ + id, + type: 'posts', + data: { + attributes: { + title: '#destroy() Test' + } + } + }); + + const result = await subject.update(request); + + expect(result).to.equal(204); + }); + + it('throws an error if the record is not found', async () => { + const request = createRequest({ + id: 10000, + type: 'posts', + data: { + attributes: { + isPublic: true + } + } + }); + + await subject.update(request).catch(err => { + expect(err).to.be.an.instanceof(Error); + }); + }); + + it('supports sparse field sets', async () => { + let item = record; + let title = Reflect.get(item, 'title'); + const id = Reflect.get(item, 'id'); + + expect(title).to.equal('#destroy() Test'); + + const request = createRequest({ + id, + type: 'posts', + data: { + attributes: { + title: 'Sparse Field Sets Work With #destroy()!' + } + }, + fields: { + posts: ['id', 'title'] + } + }); + + const result = await subject.update(request); + + assertRecord(result, ['id', 'title']); + + item = await Post.find(id); + title = Reflect.get(item, 'title'); + + expect(title).to.equal('Sparse Field Sets Work With #destroy()!'); + }); + }); + + describe('#destroy()', () => { + let record: Model; + const createRequest = (params = {}): Request => setType(() => ({ + params, + route: { + controller: subject + }, + defaultParams: { + fields: { + posts: attributes + } + } + })); + + before(async () => { + record = await Post.create({ + title: '#destroy() Test' + }); + }); + + it('returns the number `204` if the record is destroyed', async () => { + const id = Reflect.get(record, 'id'); + const result = await subject.destroy(createRequest({ id })); + + expect(result).to.equal(204); + + await Post.find(id).catch(err => { + expect(err).to.be.an.instanceof(Error); + }); + }); + + it('throws an error if the record is not found', async () => { + const request = createRequest({ id: 10000 }); + + await subject.destroy(request).catch(err => { + expect(err).to.be.an.instanceof(Error); + }); + }); + }); + + describe('#preflight()', () => { + it('returns the number `204`', async () => { + const result = await subject.preflight(); + + expect(result).to.equal(204); + }); + }); + }); +}); diff --git a/src/packages/controller/utils/find-many.js b/src/packages/controller/utils/find-many.js index 0979fd2a..2a89d867 100644 --- a/src/packages/controller/utils/find-many.js +++ b/src/packages/controller/utils/find-many.js @@ -2,7 +2,7 @@ import merge from '../../../utils/merge'; import paramsToQuery from './params-to-query'; -import type { Model } from '../../database'; +import type { Model, Query } from '../../database'; import type { Request } from '../../server'; /** @@ -17,7 +17,7 @@ export default function findMany({ model } } -}: Request): Promise> { +}: Request): Query> { const { sort, page, diff --git a/src/packages/controller/utils/find-one.js b/src/packages/controller/utils/find-one.js index 42e09001..24134109 100644 --- a/src/packages/controller/utils/find-one.js +++ b/src/packages/controller/utils/find-one.js @@ -4,6 +4,7 @@ import { Model } from '../../database'; import merge from '../../../utils/merge'; import paramsToQuery from './params-to-query'; +import type { Query } from '../../database'; import type { Request } from '../../server'; /** @@ -18,7 +19,7 @@ export default function findOne({ model } } -}: Request): Promise { +}: Request): Query { const { id, select, diff --git a/src/packages/database/model/index.js b/src/packages/database/model/index.js index 04a6e685..882cfce0 100644 --- a/src/packages/database/model/index.js +++ b/src/packages/database/model/index.js @@ -12,11 +12,9 @@ import initializeClass from './initialize-class'; import pick from '../../../utils/pick'; import omit from '../../../utils/omit'; import setType from '../../../utils/set-type'; -import tryCatch from '../../../utils/try-catch'; import underscore from '../../../utils/underscore'; import validate from './utils/validate'; import getColumns from './utils/get-columns'; -import processWriteError from './utils/process-write-error'; import type Logger from '../../logger'; import type Database from '../../database'; @@ -185,6 +183,7 @@ class Model { }); Object.freeze(this); + Object.freeze(this.rawColumnData); } NEW_RECORDS.add(this); @@ -309,81 +308,77 @@ class Model { } } - save(deep?: boolean): Promise { - return tryCatch(async () => { - const { - constructor: { - table, - logger, - primaryKey, - - hooks: { - afterUpdate, - afterSave, - afterValidation, - beforeUpdate, - beforeSave, - beforeValidation - } - } - } = this; + async save(deep?: boolean): Promise { + const { + constructor: { + table, + logger, + primaryKey, - if (typeof beforeValidation === 'function') { - await beforeValidation(this); + hooks: { + afterUpdate, + afterSave, + afterValidation, + beforeUpdate, + beforeSave, + beforeValidation + } } + } = this; - validate(this); + if (typeof beforeValidation === 'function') { + await beforeValidation(this); + } - if (typeof afterValidation === 'function') { - await afterValidation(this); - } + validate(this); - if (typeof beforeUpdate === 'function') { - await beforeUpdate(this); - } + if (typeof afterValidation === 'function') { + await afterValidation(this); + } - if (typeof beforeSave === 'function') { - await beforeSave(this); - } + if (typeof beforeUpdate === 'function') { + await beforeUpdate(this); + } - Reflect.set(this, 'updatedAt', new Date()); + if (typeof beforeSave === 'function') { + await beforeSave(this); + } - const query = table() - .where({ [primaryKey]: Reflect.get(this, primaryKey) }) - .update(getColumns(this, Array.from(this.dirtyAttributes))) - .on('query', () => { - setImmediate(() => logger.debug(sql`${query.toString()}`)); - }); + Reflect.set(this, 'updatedAt', new Date()); - if (deep) { - await Promise.all([ - query, - saveRelationships(this) - ]); + const query = table() + .where({ [primaryKey]: Reflect.get(this, primaryKey) }) + .update(getColumns(this, Array.from(this.dirtyAttributes))) + .on('query', () => { + setImmediate(() => logger.debug(sql`${query.toString()}`)); + }); - this.prevAssociations.clear(); - } else { - await query; - } + if (deep) { + await Promise.all([ + query, + saveRelationships(this) + ]); + + this.prevAssociations.clear(); + } else { + await query; + } - NEW_RECORDS.delete(this); - this.dirtyAttributes.clear(); + NEW_RECORDS.delete(this); + this.dirtyAttributes.clear(); - if (typeof afterUpdate === 'function') { - await afterUpdate(this); - } + if (typeof afterUpdate === 'function') { + await afterUpdate(this); + } - if (typeof afterSave === 'function') { - await afterSave(this); - } + if (typeof afterSave === 'function') { + await afterSave(this); + } - return this; - }, err => { - throw processWriteError(err); - }).then(() => this); + return this; } - async update(attributes: Object = {}): Promise { + async update(attributes: Object = {}): Promise { Object.assign(this, attributes); if (this.isDirty) { @@ -393,7 +388,7 @@ class Model { return this; } - async destroy(): Promise { + async destroy(): Promise { const { constructor: { primaryKey, @@ -412,7 +407,7 @@ class Model { } const query = table() - .where({ [primaryKey]: Reflect.get(this, primaryKey) }) + .where({ [primaryKey]: this.getPrimaryKey() }) .del() .on('query', () => { setImmediate(() => logger.debug(sql`${query.toString()}`)); @@ -450,140 +445,140 @@ class Model { } } - static create(props = {}) { - return tryCatch(async () => { - const { - primaryKey, - logger, - table, - - hooks: { - afterCreate, - afterSave, - afterValidation, - beforeCreate, - beforeSave, - beforeValidation - } - } = this; - - const datetime = new Date(); - const instance = Reflect.construct(this, [{ - ...props, - createdAt: datetime, - updatedAt: datetime - }, false]); - - if (typeof beforeValidation === 'function') { - await beforeValidation(instance); + static async create(props = {}): Promise { + const { + primaryKey, + logger, + table, + + hooks: { + afterCreate, + afterSave, + afterValidation, + beforeCreate, + beforeSave, + beforeValidation } + } = this; - validate(instance); + const datetime = new Date(); + const instance = Reflect.construct(this, [{ + ...props, + createdAt: datetime, + updatedAt: datetime + }, false]); - if (typeof afterValidation === 'function') { - await afterValidation(instance); - } + if (typeof beforeValidation === 'function') { + await beforeValidation(instance); + } - if (typeof beforeCreate === 'function') { - await beforeCreate(instance); - } + validate(instance); - if (typeof beforeSave === 'function') { - await beforeSave(instance); - } + if (typeof afterValidation === 'function') { + await afterValidation(instance); + } - const query = table() - .returning(primaryKey) - .insert(omit(getColumns(instance), primaryKey)) - .on('query', () => { - setImmediate(() => logger.debug(sql`${query.toString()}`)); - }); + if (typeof beforeCreate === 'function') { + await beforeCreate(instance); + } - Object.assign(instance, { - [primaryKey]: (await query)[0] - }); + if (typeof beforeSave === 'function') { + await beforeSave(instance); + } - Reflect.defineProperty(instance, 'initialized', { - value: true, - writable: false, - enumerable: false, - configurable: false + const query = table() + .returning(primaryKey) + .insert(omit(getColumns(instance), primaryKey)) + .on('query', () => { + setImmediate(() => logger.debug(sql`${query.toString()}`)); }); - Object.freeze(instance); - NEW_RECORDS.delete(instance); + const [primaryKeyValue] = await query; - if (typeof afterCreate === 'function') { - await afterCreate(instance); - } + Object.assign(instance, { + [primaryKey]: primaryKeyValue + }); - if (typeof afterSave === 'function') { - await afterSave(instance); - } + Reflect.set(instance.rawColumnData, primaryKey, primaryKeyValue); - return instance; - }, err => { - throw processWriteError(err); + Reflect.defineProperty(instance, 'initialized', { + value: true, + writable: false, + enumerable: false, + configurable: false }); + + Object.freeze(instance); + NEW_RECORDS.delete(instance); + + if (typeof afterCreate === 'function') { + await afterCreate(instance); + } + + if (typeof afterSave === 'function') { + await afterSave(instance); + } + + return instance; } - static all() { + static all(): Query> { return new Query(this).all(); } - static find(primaryKey: any) { + static find(primaryKey: any): Query { return new Query(this).find(primaryKey); } - static page(num: number) { + static page(num: number): Query> { return new Query(this).page(num); } - static limit(amount: number) { + static limit(amount: number): Query> { return new Query(this).limit(amount); } - static offset(amount: number) { + static offset(amount: number): Query> { return new Query(this).offset(amount); } - static count() { + static count(): Query { return new Query(this).count(); } - static order(attr: string, direction?: string) { + static order(attr: string, direction?: string): Query> { return new Query(this).order(attr, direction); } - static where(conditions: Object) { + static where(conditions: Object): Query> { return new Query(this).where(conditions); } - static not(conditions: Object) { + static not(conditions: Object): Query> { return new Query(this).not(conditions); } - static first() { + static first(): Query { return new Query(this).first(); } - static last() { + static last(): Query { return new Query(this).last(); } - static select(...params: Array) { + static select(...params: Array): Query> { return new Query(this).select(...params); } - static distinct(...params: Array) { + static distinct(...params: Array): Query> { return new Query(this).distinct(...params); } - static include(...relationships: Array) { + static include(...relationships: Array): Query> { return new Query(this).include(...relationships); } - static unscope(...scopes: Array) { + static unscope(...scopes: Array): Query> { return new Query(this).unscope(...scopes); } diff --git a/src/packages/database/model/utils/validate.js b/src/packages/database/model/utils/validate.js index e399dbac..d6644346 100644 --- a/src/packages/database/model/utils/validate.js +++ b/src/packages/database/model/utils/validate.js @@ -1,6 +1,5 @@ // @flow -import Validation from '../../validation'; -import { ValidationError } from '../errors'; +import Validation, { ValidationError } from '../../validation'; import pick from '../../../../utils/pick'; import entries from '../../../../utils/entries'; diff --git a/src/packages/database/query/index.js b/src/packages/database/query/index.js index d6ab430d..6956b4fd 100644 --- a/src/packages/database/query/index.js +++ b/src/packages/database/query/index.js @@ -1,24 +1,18 @@ // @flow import { camelize } from 'inflection'; -import { sql } from '../../logger'; - -import { RecordNotFoundError } from './errors'; - -import initialize from './initialize'; +import { runQuery, createRunner } from './runner'; import entries from '../../../utils/entries'; -import tryCatch from '../../../utils/try-catch'; +import scopesFor from './utils/scopes-for'; import formatSelect from './utils/format-select'; -import buildResults from './utils/build-results'; -import getFindParam from './utils/get-find-param'; -import type { Model } from '../index'; // eslint-disable-line no-unused-vars +import type Model from '../model'; /** * @private */ -class Query { +class Query<+T: any> extends Promise { /** * @private */ @@ -27,12 +21,12 @@ class Query { /** * @private */ - snapshots: Array<[string, mixed]>; + isFind: boolean; /** * @private */ - isFind: boolean; + snapshots: Array<[string, mixed]>; /** * @private @@ -50,6 +44,19 @@ class Query { relationships: Object; constructor(model: Class) { + let resolve; + let reject; + + super((res, rej) => { + resolve = res; + reject = rej; + }); + + createRunner(this, { + resolve, + reject + }); + Object.defineProperties(this, { model: { value: model, @@ -87,18 +94,23 @@ class Query { } }); - return initialize(this); + Object.defineProperties(this, scopesFor(this)); + } + + // $FlowIgnore + static get [Symbol.species]() { + return Promise; } - all() { + all(): this { return this; } - not(conditions: Object = {}) { + not(conditions: Object = {}): this { return this.where(conditions, true); } - find(primaryKey: any) { + find(primaryKey: any): this { Object.assign(this, { isFind: true, collection: false @@ -115,7 +127,7 @@ class Query { return this; } - page(num: number) { + page(num: number): this { if (this.shouldCount) { return this; } else { @@ -135,7 +147,7 @@ class Query { } } - limit(amount: number) { + limit(amount: number): this { if (!this.shouldCount) { this.snapshots.push(['limit', amount]); } @@ -143,7 +155,7 @@ class Query { return this; } - order(attr: string, direction: string = 'ASC') { + order(attr: string, direction: string = 'ASC'): this { if (!this.shouldCount) { const columnName = this.model.columnNameFor(attr); @@ -162,7 +174,7 @@ class Query { return this; } - where(conditions: Object = {}, not: boolean = false) { + where(conditions: Object = {}, not: boolean = false): this { const { model: { tableName @@ -200,7 +212,7 @@ class Query { return this; } - first() { + first(): this { if (!this.shouldCount) { const willSort = this.snapshots.some(([method]) => method === 'orderBy'); @@ -216,7 +228,7 @@ class Query { return this; } - last() { + last(): this { if (!this.shouldCount) { const willSort = this.snapshots.some(([method]) => method === 'orderBy'); @@ -232,7 +244,7 @@ class Query { return this; } - count() { + count(): Query { const validName = /^(where(Not)?(In)?)$/g; Object.assign(this, { @@ -247,7 +259,7 @@ class Query { return this; } - offset(amount: number) { + offset(amount: number): this { if (!this.shouldCount) { this.snapshots.push(['offset', amount]); } @@ -255,7 +267,7 @@ class Query { return this; } - select(...attrs: Array) { + select(...attrs: Array): this { if (!this.shouldCount) { this.snapshots.push(['select', formatSelect(this.model, attrs)]); } @@ -263,7 +275,7 @@ class Query { return this; } - distinct(...attrs: Array) { + distinct(...attrs: Array): this { if (!this.shouldCount) { this.snapshots.push(['distinct', formatSelect(this.model, attrs)]); } @@ -271,7 +283,7 @@ class Query { return this.select(); } - include(...relationships: Array) { + include(...relationships: Array): this { let included; if (!this.shouldCount) { @@ -372,7 +384,7 @@ class Query { return this; } - unscope(...scopes: Array) { + unscope(...scopes: Array): this { if (scopes.length) { scopes = scopes.filter(scope => { return scope === 'order' ? 'orderBy' : scope; @@ -388,92 +400,20 @@ class Query { return this; } - /** - * @private - */ - async run(): Promise> { - let results; - - const { - model, - isFind, - snapshots, - collection, - shouldCount, - relationships - } = this; - - if (!shouldCount && !snapshots.some(([name]) => name === 'select')) { - this.select(...this.model.attributeNames); - } - - const records: any = snapshots.reduce(( - query, - [name, params] - ) => { - if (!shouldCount && name === 'includeSelect') { - name = 'select'; - } - - const method = Reflect.get(query, name); - - if (!Array.isArray(params)) { - params = [params]; - } - - return Reflect.apply(method, query, params); - }, model.table()); - - if (model.store.debug) { - records.on('query', () => { - setImmediate(() => model.logger.debug(sql`${records.toString()}`)); - }); - } - - if (shouldCount) { - let [{ countAll: count }] = await records; - count = parseInt(count, 10); - - return Number.isFinite(count) ? count : 0; - } else { - results = await buildResults({ - model, - records, - relationships - }); - - if (collection) { - return results; - } else { - const [result] = results; - - if (!result && isFind) { - throw new RecordNotFoundError(model, getFindParam(this)); - } - - return result; - } - } + then( + onFulfilled?: (value: T) => Promise | U, + onRejected?: (error: Error) => Promise | U + ): Promise { + runQuery(this); + return super.then(onFulfilled, onRejected); } - then( - onData: ?(data: number | ?Model | Array) => void, - onError: ?(err: Error) => void - ): Promise> { - return tryCatch(async () => { - const data = await this.run(); - - if (typeof onData === 'function') { - onData(data); - } - }, (err) => { - if (typeof onError === 'function') { - onError(err); - } - }); + catch(onRejected?: (error: Error) => ?Promise | U): Promise { + runQuery(this); + return super.catch(onRejected); } - static from(src: any) { + static from(src: any): Query { const { model, snapshots, @@ -496,3 +436,4 @@ class Query { } export default Query; +export { RecordNotFoundError } from './errors'; diff --git a/src/packages/database/query/initialize.js b/src/packages/database/query/initialize.js deleted file mode 100644 index dd80f35f..00000000 --- a/src/packages/database/query/initialize.js +++ /dev/null @@ -1,25 +0,0 @@ -// @flow -import type { Query } from '../index'; - -/** - * @private - */ -export default function initialize(instance: Query) { - return new Proxy(instance, { - get(target, key, receiver): ?mixed | void { - if (target.model.hasScope(key)) { - const scope = Reflect.get(target.model.scopes, key); - - return (...args) => { - let { snapshots } = Reflect.apply(scope, target.model, args); - snapshots = snapshots.map(snapshot => [...snapshot, key]); - - target.snapshots.push(...snapshots); - return receiver; - }; - } else { - return Reflect.get(target, key); - } - } - }); -} diff --git a/src/packages/database/query/runner/constants.js b/src/packages/database/query/runner/constants.js new file mode 100644 index 00000000..b6fdda31 --- /dev/null +++ b/src/packages/database/query/runner/constants.js @@ -0,0 +1,4 @@ +// @flow +import type Query from '../index'; + +export const RUNNERS: WeakMap, () => Promise> = new WeakMap(); diff --git a/src/packages/database/query/runner/index.js b/src/packages/database/query/runner/index.js new file mode 100644 index 00000000..591090ec --- /dev/null +++ b/src/packages/database/query/runner/index.js @@ -0,0 +1,103 @@ +// @flow +import { RUNNERS } from './constants'; + +import { RecordNotFoundError } from '../errors'; + +import { sql } from '../../../logger'; +import getFindParam from './utils/get-find-param'; +import buildResults from './utils/build-results'; + +import type Query from '../index'; + +/** + * @private + */ +export function createRunner(target: Query<*>, opts: { + resolve?: (value: any) => void; + reject?: (error: Error) => void; +}): void { + if (opts.resolve && opts.reject) { + const { resolve, reject } = opts; + let didRun = false; + + RUNNERS.set(target, async function queryRunner() { + let results; + const { + model, + isFind, + snapshots, + collection, + shouldCount, + relationships + } = target; + + if (didRun) { + return; + } else { + didRun = true; + } + + if (!shouldCount && !snapshots.some(([name]) => name === 'select')) { + target.select(...target.model.attributeNames); + } + + const records: any = snapshots.reduce(( + query, + [name, params] + ) => { + if (!shouldCount && name === 'includeSelect') { + name = 'select'; + } + + const method = Reflect.get(query, name); + + if (!Array.isArray(params)) { + params = [params]; + } + + return Reflect.apply(method, query, params); + }, model.table()); + + if (model.store.debug) { + records.on('query', () => { + setImmediate(() => model.logger.debug(sql`${records.toString()}`)); + }); + } + + if (shouldCount) { + let [{ countAll: count }] = await records; + count = parseInt(count, 10); + + resolve(Number.isFinite(count) ? count : 0); + } else { + results = await buildResults({ + model, + records, + relationships + }); + + if (collection) { + resolve(results); + } else { + const [result] = results; + + if (!result && isFind) { + const err = new RecordNotFoundError(model, getFindParam(target)); + + reject(err); + } + + resolve(result); + } + } + }); + } +} + +export function runQuery(target: Query<*>): void { + const runner = RUNNERS.get(target); + + if (runner) { + runner(); + } +} diff --git a/src/packages/database/query/utils/build-results.js b/src/packages/database/query/runner/utils/build-results.js similarity index 92% rename from src/packages/database/query/utils/build-results.js rename to src/packages/database/query/runner/utils/build-results.js index 6c768265..03f45f38 100644 --- a/src/packages/database/query/utils/build-results.js +++ b/src/packages/database/query/runner/utils/build-results.js @@ -1,13 +1,13 @@ // @flow import { camelize, singularize } from 'inflection'; -import { NEW_RECORDS } from '../../constants'; +import { NEW_RECORDS } from '../../../constants'; -import Model from '../../model'; +import Model from '../../../model'; -import entries from '../../../../utils/entries'; -import underscore from '../../../../utils/underscore'; -import promiseHash from '../../../../utils/promise-hash'; +import entries from '../../../../../utils/entries'; +import underscore from '../../../../../utils/underscore'; +import promiseHash from '../../../../../utils/promise-hash'; /** * @private diff --git a/src/packages/database/query/utils/get-find-param.js b/src/packages/database/query/runner/utils/get-find-param.js similarity index 78% rename from src/packages/database/query/utils/get-find-param.js rename to src/packages/database/query/runner/utils/get-find-param.js index 29fc4d56..70d7e99f 100644 --- a/src/packages/database/query/utils/get-find-param.js +++ b/src/packages/database/query/runner/utils/get-find-param.js @@ -1,7 +1,7 @@ // @flow -import isObject from '../../../../utils/is-object'; +import isObject from '../../../../../utils/is-object'; -import type { Query } from '../../index'; +import type Query from '../../index'; export default function getFindParam({ isFind, @@ -11,7 +11,7 @@ export default function getFindParam({ tableName, primaryKey } -}: Query) { +}: Query<*>) { if (isFind) { const snapshot = snapshots.find(([method]) => method === 'where'); diff --git a/src/packages/database/query/utils/scopes-for.js b/src/packages/database/query/utils/scopes-for.js new file mode 100644 index 00000000..fe45c7ec --- /dev/null +++ b/src/packages/database/query/utils/scopes-for.js @@ -0,0 +1,37 @@ +// @flow +import type Query from '../index'; + +export default function scopesFor(target: Query): { + [key: string]: () => Query +} { + return Object.keys(target.model.scopes).reduce((scopes, name) => ({ + ...scopes, + [name]: { + get() { + const scope = function (...args: Array) { + const fn = Reflect.get(target.model, name); + const { snapshots } = Reflect.apply(fn, target.model, args); + + target.snapshots = [ + ...target.snapshots, + ...snapshots.map(snapshot => [ + ...snapshot, + name + ]) + ]; + + return target; + }; + + Reflect.defineProperty(scope, 'name', { + value: name, + writable: false, + enumerable: false, + configurable: false + }); + + return scope; + } + } + }), {}); +} diff --git a/src/packages/database/test/database.test.js b/src/packages/database/test/database.test.js new file mode 100644 index 00000000..e750bda3 --- /dev/null +++ b/src/packages/database/test/database.test.js @@ -0,0 +1,75 @@ +// @flow +import { expect } from 'chai'; +import { it, before, describe } from 'mocha'; + +import Database from '../index'; +import { getTestApp } from '../../../../test/utils/get-test-app'; + +const DATABASE_DRIVER: string = Reflect.get(process.env, 'DATABASE_DRIVER'); +const DATABASE_USERNAME: string = Reflect.get(process.env, 'DATABASE_USERNAME'); +const DATABASE_PASSWORD: string = Reflect.get(process.env, 'DATABASE_PASSWORD'); + +describe('module "database"', () => { + describe('class Database', () => { + let createDatabase; + + before(async () => { + const { path, models, logger } = await getTestApp(); + + createDatabase = (config = { + development: { + driver: 'sqlite3', + database: 'lux_test' + }, + test: { + driver: DATABASE_DRIVER || 'sqlite3', + database: 'lux_test', + username: DATABASE_USERNAME, + password: DATABASE_PASSWORD + }, + production: { + driver: 'sqlite3', + database: 'lux_test' + } + }) => new Database({ + path, + models, + logger, + config, + checkMigrations: false + }); + }); + + describe('#constructor()', () => { + it('creates an instance of `Database`', async () => { + const result = await createDatabase(); + + expect(result).to.be.an.instanceof(Database); + }); + }); + + describe('#modelFor()', () => { + let subject; + + before(async () => { + subject = await createDatabase(); + }); + + it('works with a singular key', () => { + const result = subject.modelFor('post'); + + expect(result).to.be.ok; + }); + + it('works with a plural key', () => { + const result = subject.modelFor('posts'); + + expect(result).to.be.ok; + }); + + it('throws an error if a model does not exist', () => { + expect(() => subject.modelFor('not-a-model-name')).to.throw(Error); + }); + }); + }); +}); diff --git a/src/packages/database/test/migration.test.js b/src/packages/database/test/migration.test.js new file mode 100644 index 00000000..0ae3fe5b --- /dev/null +++ b/src/packages/database/test/migration.test.js @@ -0,0 +1,77 @@ +// @flow +import { expect } from 'chai'; +import { it, describe, before, after } from 'mocha'; + +import Migration, { generateTimestamp } from '../migration'; + +import { getTestApp } from '../../../../test/utils/get-test-app'; + +describe('module "database/migration"', () => { + describe('class Migration', () => { + let store; + + before(async () => { + const app = await getTestApp(); + + store = app.store; + }); + + describe('#run()', () => { + const tableName = 'migration_test'; + let subject; + + before(async () => { + subject = new Migration(schema => { + return schema.createTable(tableName, table => { + table.increments(); + + table + .boolean('success') + .index() + .notNullable() + .defaultTo(false); + + table.timestamps(); + table.index(['created_at', 'updated_at']); + }); + }); + }); + + after(async () => { + await store.schema().dropTable(tableName); + }); + + it('runs a migration function', async () => { + await subject.run(store.schema()); + + const result = await store.connection(tableName).columnInfo(); + + expect(result).to.have.all.keys([ + 'id', + 'success', + 'created_at', + 'updated_at' + ]); + + Object.keys(result).forEach(key => { + const value = Reflect.get(result, key); + + expect(value).to.have.all.keys([ + 'type', + 'nullable', + 'maxLength', + 'defaultValue' + ]); + }); + }); + }); + }); + + describe('#generateTimestamp()', () => { + it('generates a timestamp string', () => { + const result = generateTimestamp(); + + expect(result).to.be.a('string').and.match(/^\d{16}$/g); + }); + }); +}); diff --git a/src/packages/database/test/model.test.js b/src/packages/database/test/model.test.js new file mode 100644 index 00000000..01a481e6 --- /dev/null +++ b/src/packages/database/test/model.test.js @@ -0,0 +1,1451 @@ +// @flow +import { spy } from 'sinon'; +import { expect } from 'chai'; +import { it, describe, before, after, beforeEach, afterEach } from 'mocha'; + +import Model from '../model'; +import Query, { RecordNotFoundError } from '../query'; +import { ValidationError } from '../validation'; + +import setType from '../../../utils/set-type'; +import { getTestApp } from '../../../../test/utils/get-test-app'; + +describe('module "database/model"', () => { + describe('class Model', () => { + let store; + let User: Class; + + before(async () => { + const app = await getTestApp(); + + store = app.store; + User = setType(() => app.models.get('user')); + }); + + describe('.initialize()', () => { + class Subject extends Model { + static tableName = 'posts'; + + static belongsTo = { + user: { + inverse: 'posts' + } + }; + + static hasMany = { + comments: { + inverse: 'post' + }, + + reactions: { + inverse: 'post' + }, + + tags: { + inverse: 'posts', + through: 'categorization' + } + }; + + static hooks = { + afterCreate: async instance => console.log(instance), + beforeDestroy: async instance => console.log(instance), + duringDestroy: async () => console.log('This hook should be removed.') + }; + + static scopes = { + isPublic() { + return this.where({ + isPublic: true + }); + }, + + isDraft() { + return this.where({ + isPublic: false + }); + } + }; + + static validates = { + title: str => Boolean(str), + notAnAttribute: () => false + }; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('adds a `store` property to the `Model`', () => { + expect(Subject.store).to.equal(store); + }); + + it('adds a `table` property to the `Model`', () => { + expect(Subject.table).to.be.a('function'); + }); + + it('adds a `logger` property to the `Model`', () => { + expect(Subject.logger).to.equal(store.logger); + }); + + it('adds an `attributes` property to the `Model`', () => { + expect(Subject.attributes).to.have.all.keys([ + 'id', + 'body', + 'title', + 'isPublic', + 'userId', + 'createdAt', + 'updatedAt' + ]); + + Object.keys(Subject.attributes).forEach(key => { + const value = Reflect.get(Subject.attributes, key); + + expect(value).to.have.all.keys([ + 'type', + 'docName', + 'nullable', + 'maxLength', + 'columnName', + 'defaultValue' + ]); + }); + }); + + it('adds an `attributeNames` property to the `Model`', () => { + expect(Subject.attributeNames).to.include.all.members([ + 'id', + 'body', + 'title', + 'isPublic', + 'userId', + 'createdAt', + 'updatedAt' + ]); + }); + + it('adds attribute accessors on the `prototype`', () => { + Object.keys(Subject.attributes).forEach(key => { + const desc = Reflect.getOwnPropertyDescriptor(Subject.prototype, key); + + expect(desc).to.have.property('get').and.be.a('function'); + expect(desc).to.have.property('set').and.be.a('function'); + }); + }); + + it('adds a `hasOne` property to the `Model`', () => { + expect(Subject.hasOne).to.deep.equal({}); + }); + + it('adds a `hasMany` property to the `Model`', () => { + expect(Subject.hasMany).to.have.all.keys([ + 'tags', + 'comments', + 'reactions' + ]); + + Object.keys(Subject.hasMany).forEach(key => { + const value = Reflect.get(Subject.hasMany, key); + + expect(value).to.be.an('object'); + expect(value).to.have.property('type').and.equal('hasMany'); + expect(Reflect.ownKeys(value)).to.include.all.members([ + 'type', + 'model', + 'inverse', + 'through', + 'foreignKey' + ]); + }); + }); + + it('adds a `belongsTo` property to the `Model`', () => { + expect(Subject.belongsTo).to.have.all.keys(['user']); + + Object.keys(Subject.belongsTo).forEach(key => { + const value = Reflect.get(Subject.belongsTo, key); + + expect(value).to.be.an('object'); + expect(value).to.have.property('type').and.equal('belongsTo'); + expect(Reflect.ownKeys(value)).to.include.all.members([ + 'type', + 'model', + 'inverse', + 'foreignKey' + ]); + }); + }); + + it('adds a `relationships` property to the `Model`', () => { + expect(Subject.relationships).to.have.all.keys([ + 'user', + 'tags', + 'comments', + 'reactions' + ]); + + Object.keys(Subject.relationships).forEach(key => { + const value = Reflect.get(Subject.relationships, key); + + expect(value).to.have.property('type'); + + if (value.type === 'hasMany') { + expect(Reflect.ownKeys(value)).to.include.all.members([ + 'type', + 'model', + 'inverse', + 'through', + 'foreignKey' + ]); + } else { + expect(Reflect.ownKeys(value)).to.include.all.members([ + 'type', + 'model', + 'inverse', + 'foreignKey' + ]); + } + }); + }); + + it('adds a `relationshipNames` property to the `Model`', () => { + expect(Subject.relationshipNames).to.include.all.members([ + 'user', + 'tags', + 'comments', + 'reactions' + ]); + }); + + it('adds relationship accessors to the `prototype`', () => { + Object.keys(Subject.relationships).forEach(key => { + const desc = Reflect.getOwnPropertyDescriptor(Subject.prototype, key); + + expect(desc).to.have.property('get').and.be.a('function'); + expect(desc).to.have.property('set').and.be.a('function'); + }); + }); + + it('removes invalid hooks from the `hooks` property', () => { + expect(Subject.hooks).to.have.all.keys([ + 'afterCreate', + 'beforeDestroy' + ]); + + expect(Subject.hooks.afterCreate).to.be.a('function'); + expect(Subject.hooks.beforeDestroy).to.be.a('function'); + }); + + it('adds each scope to `Model`', () => { + expect(Subject.scopes).to.have.all.keys([ + 'isDraft', + 'isPublic' + ]); + + Object.keys(Subject.scopes).forEach(key => { + const value = Reflect.get(Subject, key); + + expect(value).to.be.a('function'); + }); + }); + + it('removes invalid validations from the `validates` property', () => { + expect(Subject.validates).to.have.all.keys(['title']); + expect(Subject.validates.title).to.be.a('function'); + }); + + it('adds an `modelName` property to the `Model`', () => { + expect(Subject.modelName).to.equal('subject'); + }); + + it('adds an `modelName` property to the `prototype`', () => { + expect(Subject.prototype.modelName).to.equal('subject'); + }); + + it('adds an `resourceName` property to the `Model`', () => { + expect(Subject.resourceName).to.equal('subjects'); + }); + + it('adds an `resourceName` property to the `prototype`', () => { + expect(Subject.prototype.resourceName).to.equal('subjects'); + }); + + it('adds an `initialized` property to the `Model`', () => { + expect(Subject.initialized).to.be.true; + }); + }); + + describe('.create()', () => { + let result: Subject; + + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + after(async () => { + await result.destroy(); + }); + + it('constructs and persists a `Model` instance', async () => { + const body = 'Contents of "Test Post"...'; + const title = 'Test Post'; + + result = await Subject.create({ + body, + title, + isPublic: true + }); + + expect(result).to.be.an.instanceof(Subject); + + expect(result).to.have.property('id').and.be.a('number'); + expect(result).to.have.property('body', body); + expect(result).to.have.property('title', title); + expect(result).to.have.property('isPublic', true); + expect(result).to.have.property('createdAt').and.be.an.instanceof(Date); + expect(result).to.have.property('updatedAt').and.be.an.instanceof(Date); + }); + }); + + describe('.all()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.all(); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.find()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.find(); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.page()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.page(1); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.limit()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.limit(25); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.offset()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.offset(0); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.count()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.count(); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.order()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.order('createdAt', 'ASC'); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.where()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.where({ + isPublic: true + }); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.not()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.not({ + isPublic: true + }); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.first()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.first(); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.last()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.last(); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.select()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.select('title', 'createdAt'); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.distinct()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.distinct('title'); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.include()', () => { + class Subject extends Model { + static tableName = 'posts'; + + static hasMany = { + comments: { + inverse: 'post' + } + }; + + static belongsTo = { + user: { + inverse: 'posts' + } + }; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.include('user', 'comments'); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.unscope()', () => { + class Subject extends Model { + static tableName = 'posts'; + + static scopes = { + isPublic() { + return this.where({ + isPublic: true + }); + } + }; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns an instance of `Query`', () => { + const result = Subject.unscope('isPublic'); + + expect(result).to.be.an.instanceof(Query); + }); + }); + + describe('.hasScope()', () => { + class Subject extends Model { + static scopes = { + mostRecent() { + return this.order('createdAt', 'DESC'); + } + }; + } + + it('returns true if a `Model` has a scope', () => { + const result = Subject.hasScope('mostRecent'); + + expect(result).to.be.true; + }); + + it('returns false if a `Model` does not have a scope', () => { + const result = Subject.hasScope('mostPopular'); + + expect(result).to.be.false; + }); + }); + + describe('.isInstance()', () => { + class SubjectA extends Model { + static tableName = 'posts'; + } + + class SubjectB extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Promise.all([ + SubjectA.initialize(store, () => { + return store.connection(SubjectA.tableName); + }), + SubjectB.initialize(store, () => { + return store.connection(SubjectB.tableName); + }) + ]); + }); + + it('returns true if an object is an instance of the `Model`', () => { + const instance = new SubjectA(); + const result = SubjectA.isInstance(instance); + + expect(result).to.be.true; + }); + + it('returns false if an object is an instance of the `Model`', () => { + const instance = new SubjectA(); + const result = SubjectB.isInstance(instance); + + expect(result).to.be.false; + }); + }); + + describe('.columnFor()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns the column data for an attribute if it exists', () => { + const result = Subject.columnFor('isPublic'); + + expect(result).to.be.an('object').and.have.all.keys([ + 'type', + 'docName', + 'nullable', + 'maxLength', + 'columnName', + 'defaultValue' + ]); + }); + }); + + describe('.columnNameFor()', () => { + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns the column name for an attribute if it exists', () => { + const result = Subject.columnNameFor('isPublic'); + + expect(result).to.equal('is_public'); + }); + }); + + describe('.relationshipFor()', () => { + class Subject extends Model { + static tableName = 'posts'; + + static belongsTo = { + user: { + inverse: 'posts' + } + }; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + it('returns the data for a relationship if it exists', () => { + const result = Subject.relationshipFor('user'); + + expect(Reflect.ownKeys(result)).to.include.all.members([ + 'type', + 'model', + 'inverse', + 'foreignKey' + ]); + }); + }); + + describe('.hooks', () => { + const assertCreateHook = (instance, hookSpy) => { + expect(hookSpy.calledWith(instance)).to.be.true; + }; + + const assertSaveHook = async (instance, hookSpy) => { + hookSpy.reset(); + + instance.isPublic = true; + await instance.save(); + + expect(hookSpy.calledWith(instance)).to.be.true; + }; + + const assertUpdateHook = async (instance, hookSpy) => { + hookSpy.reset(); + + await instance.update({ + isPublic: true + }); + + expect(hookSpy.calledWith(instance)).to.be.true; + }; + + const assertDestroyHook = async (instance, hookSpy) => { + await instance.destroy(); + expect(hookSpy.calledWith(instance)).to.be.true; + }; + + describe('.afterCreate()', () => { + let hookSpy; + let instance; + + class Subject extends Model { + isPublic: boolean; + + static tableName = 'posts'; + + static hooks = { + async afterCreate() {} + }; + } + + before(async () => { + hookSpy = spy(Subject.hooks, 'afterCreate'); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + + instance = await Subject.create({ + title: 'Test Hook (afterCreate)', + isPublic: false + }); + }); + + after(async () => { + await instance.destroy(); + hookSpy.reset(); + }); + + it('runs when .create() is called', () => { + assertCreateHook(instance, hookSpy); + }); + }); + + describe('.afterDestroy()', () => { + let hookSpy; + let instance; + + class Subject extends Model { + static tableName = 'posts'; + + static hooks = { + async afterDestroy() {} + }; + } + + before(async () => { + hookSpy = spy(Subject.hooks, 'afterDestroy'); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + + instance = await Subject.create({ + title: 'Test Hook (afterDestroy)', + isPublic: false + }); + }); + + it('runs when #destroy is called', async () => { + await assertDestroyHook(instance, hookSpy); + }); + }); + + describe('.afterSave()', () => { + let hookSpy; + let instance; + + class Subject extends Model { + isPublic: boolean; + + static tableName = 'posts'; + + static hooks = { + async afterSave() {} + }; + } + + before(async () => { + hookSpy = spy(Subject.hooks, 'afterSave'); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + beforeEach(async () => { + instance = await Subject.create({ + title: 'Test Hook (afterSave)', + isPublic: false + }); + }); + + afterEach(async () => { + await instance.destroy(); + hookSpy.reset(); + }); + + it('runs when .create() is called', () => { + assertCreateHook(instance, hookSpy); + }); + + it('runs when #save() is called', async () => { + await assertSaveHook(instance, hookSpy); + }); + + it('runs when #update() is called', async () => { + await assertUpdateHook(instance, hookSpy); + }); + }); + + describe('.afterUpdate()', () => { + let hookSpy; + let instance; + + class Subject extends Model { + isPublic: boolean; + + static tableName = 'posts'; + + static hooks = { + async afterUpdate() {} + }; + } + + before(async () => { + hookSpy = spy(Subject.hooks, 'afterUpdate'); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + beforeEach(async () => { + instance = await Subject.create({ + title: 'Test Hook (afterUpdate)', + isPublic: false + }); + }); + + afterEach(async () => { + await instance.destroy(); + hookSpy.reset(); + }); + + it('runs when #save() is called', async () => { + await assertSaveHook(instance, hookSpy); + }); + + it('runs when #update() is called', async () => { + await assertUpdateHook(instance, hookSpy); + }); + }); + + describe('.afterValidation()', () => { + let hookSpy; + let instance; + + class Subject extends Model { + isPublic: boolean; + + static tableName = 'posts'; + + static hooks = { + async afterValidation() {} + }; + } + + before(async () => { + hookSpy = spy(Subject.hooks, 'afterValidation'); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + beforeEach(async () => { + instance = await Subject.create({ + title: 'Test Hook (afterValidation)', + isPublic: false + }); + }); + + afterEach(async () => { + await instance.destroy(); + hookSpy.reset(); + }); + + it('runs when .create() is called', () => { + assertCreateHook(instance, hookSpy); + }); + + it('runs when #save() is called', async () => { + await assertSaveHook(instance, hookSpy); + }); + + it('runs when #update() is called', async () => { + await assertUpdateHook(instance, hookSpy); + }); + }); + + describe('.beforeCreate()', () => { + let hookSpy; + let instance; + + class Subject extends Model { + isPublic: boolean; + + static tableName = 'posts'; + + static hooks = { + async beforeCreate() {} + }; + } + + before(async () => { + hookSpy = spy(Subject.hooks, 'beforeCreate'); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + + instance = await Subject.create({ + title: 'Test Hook (beforeCreate)', + isPublic: false + }); + }); + + after(async () => { + await instance.destroy(); + }); + + it('runs when .create() is called', () => { + assertCreateHook(instance, hookSpy); + }); + }); + + describe('.beforeDestroy()', () => { + let hookSpy; + let instance; + + class Subject extends Model { + isPublic: boolean; + + static tableName = 'posts'; + + static hooks = { + async beforeDestroy() {} + }; + } + + before(async () => { + hookSpy = spy(Subject.hooks, 'beforeDestroy'); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + + instance = await Subject.create({ + title: 'Test Hook (beforeDestroy)', + isPublic: false + }); + }); + + after(async () => { + await instance.destroy(); + }); + + it('runs when #destroy is called', async () => { + await assertDestroyHook(instance, hookSpy); + }); + }); + + describe('.beforeSave()', () => { + let hookSpy; + let instance; + + class Subject extends Model { + isPublic: boolean; + + static tableName = 'posts'; + + static hooks = { + async beforeSave() {} + }; + } + + before(async () => { + hookSpy = spy(Subject.hooks, 'beforeSave'); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + beforeEach(async () => { + instance = await Subject.create({ + title: 'Test Hook (beforeSave)', + isPublic: false + }); + }); + + afterEach(async () => { + await instance.destroy(); + hookSpy.reset(); + }); + + it('runs when .create() is called', () => { + assertCreateHook(instance, hookSpy); + }); + + it('runs when #save() is called', async () => { + await assertSaveHook(instance, hookSpy); + }); + + it('runs when #update() is called', async () => { + await assertUpdateHook(instance, hookSpy); + }); + }); + + describe('.beforeUpdate()', () => { + let hookSpy; + let instance; + + class Subject extends Model { + isPublic: boolean; + + static tableName = 'posts'; + + static hooks = { + async beforeUpdate() {} + }; + } + + before(async () => { + hookSpy = spy(Subject.hooks, 'beforeUpdate'); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + beforeEach(async () => { + instance = await Subject.create({ + title: 'Test Hook (beforeUpdate)', + isPublic: false + }); + }); + + afterEach(async () => { + await instance.destroy(); + hookSpy.reset(); + }); + + it('runs when #save() is called', async () => { + await assertSaveHook(instance, hookSpy); + }); + + it('runs when #update() is called', async () => { + await assertUpdateHook(instance, hookSpy); + }); + }); + + describe('.beforeValidation()', () => { + let hookSpy; + let instance; + + class Subject extends Model { + isPublic: boolean; + + static tableName = 'posts'; + + static hooks = { + async beforeValidation() {} + }; + } + + before(async () => { + hookSpy = spy(Subject.hooks, 'beforeValidation'); + + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + beforeEach(async () => { + instance = await Subject.create({ + title: 'Test Hook (beforeValidation)', + isPublic: false + }); + }); + + afterEach(async () => { + await instance.destroy(); + hookSpy.reset(); + }); + + it('runs when .create() is called', () => { + assertCreateHook(instance, hookSpy); + }); + + it('runs when #save() is called', async () => { + await assertSaveHook(instance, hookSpy); + }); + + it('runs when #update() is called', async () => { + await assertUpdateHook(instance, hookSpy); + }); + }); + }); + + describe('#save()', () => { + const instances = new Set(); + let instance: Subject; + + class Subject extends Model { + id: number; + user: Model; + title: string; + isPublic: boolean; + + static tableName = 'posts'; + + static hasMany = { + comments: { + inverse: 'posts', + foreignKey: 'post_id' + } + }; + + static belongsTo = { + user: { + inverse: 'posts' + } + }; + + static validates = { + title: str => Boolean(str) + }; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + beforeEach(async () => { + instance = await Subject.create({ + title: 'Test Post', + isPublic: false + }); + }); + + afterEach(async () => { + await Promise.all([ + instance.destroy(), + ...Array.from(instances).map(record => { + return record.destroy().then(() => { + instances.delete(record); + }); + }) + ]); + }); + + it('can persist dirty attributes', async () => { + instance.isPublic = true; + await instance.save(); + + expect(instance).to.have.property('isPublic', true); + + const result = await Subject.find(instance.id); + + expect(result).to.have.property('isPublic', true); + }); + + it('can persist dirty relationships', async () => { + const userInstance = await User.create({ + name: 'Test User', + email: 'test-user@postlight.com', + password: 'test12345678' + }); + + instances.add(userInstance); + + instance.user = userInstance; + await instance.save(true); + + const { + rawColumnData: { + user, + userId + } + } = await Subject + .find(instance.id) + .include('user'); + + expect(user).to.be.an('object'); + expect(user).to.have.property('id', userId); + expect(user).to.have.property('name', 'Test User'); + expect(user).to.have.property('email', 'test-user@postlight.com'); + }); + + it('fails if a validation is not met', async () => { + instance.title = ''; + await instance.save().catch(err => { + expect(err).to.be.an.instanceof(ValidationError); + }); + + expect(instance).to.have.property('title', 'Test Post'); + + const result = await Subject.find(instance.id); + + expect(result).to.have.property('title', 'Test Post'); + }); + }); + + describe('#update()', () => { + let instance: Subject; + + class Subject extends Model { + id: number; + title: string; + isPublic: boolean; + + static tableName = 'posts'; + + static validates = { + title: str => Boolean(str) + }; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + }); + + beforeEach(async () => { + instance = await Subject.create({ + title: 'Test Post', + isPublic: false + }); + }); + + afterEach(async () => { + await instance.destroy(); + }); + + it('can set and persist attributes', async () => { + const body = 'Lots of content...'; + + await instance.update({ + body, + isPublic: true + }); + + expect(instance).to.have.property('body', body); + expect(instance).to.have.property('isPublic', true); + + const result = await Subject.find(instance.id); + + expect(result).to.have.property('body', body); + expect(result).to.have.property('isPublic', true); + }); + + it('fails if a validation is not met', async () => { + await instance + .update({ + title: '', + isPublic: true + }) + .catch(err => { + expect(err).to.be.an.instanceof(ValidationError); + }); + + expect(instance).to.have.property('title', 'Test Post'); + expect(instance).to.have.property('isPublic', true); + + const result = await Subject.find(instance.id); + expect(result).to.have.property('title', 'Test Post'); + }); + }); + + describe('#destroy()', () => { + let instance: Subject; + + class Subject extends Model { + id: number; + + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + + instance = await Subject.create({ + title: 'Test Post' + }); + }); + + after(async () => { + await instance.destroy(); + }); + + it('removes the record from the database', async () => { + await instance.destroy(); + await Subject.find(instance.id).catch(err => { + expect(err).to.be.an.instanceof(RecordNotFoundError); + }); + }); + }); + + describe('#getAttributes()', () => { + let instance: Subject; + + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + + instance = await Subject.create({ + body: 'Lots of content...', + title: 'Test Post', + isPublic: true + }); + }); + + after(async () => { + await instance.destroy(); + }); + + it('returns a pojo containing the requested attributes', () => { + const result = instance.getAttributes('body', 'title'); + + expect(result).to.deep.equal({ + body: 'Lots of content...', + title: 'Test Post' + }); + }); + }); + + describe('#getPrimaryKey()', () => { + let instance: Subject; + + class Subject extends Model { + static tableName = 'posts'; + } + + before(async () => { + await Subject.initialize(store, () => { + return store.connection(Subject.tableName); + }); + + instance = await Subject.create({ + title: 'Test Post' + }); + }); + + after(async () => { + await instance.destroy(); + }); + + it('returns the value of `instance[Model.primaryKey]`', () => { + const result = instance.getPrimaryKey(); + + expect(result).to.be.a('number'); + }); + }); + }); +}); diff --git a/src/packages/database/test/query.test.js b/src/packages/database/test/query.test.js new file mode 100644 index 00000000..3863890c --- /dev/null +++ b/src/packages/database/test/query.test.js @@ -0,0 +1,893 @@ +// @flow +import { expect } from 'chai'; +import { it, describe, before, beforeEach } from 'mocha'; + +import Query from '../query'; +import Model from '../model'; + +import setType from '../../../utils/set-type'; +import { getTestApp } from '../../../../test/utils/get-test-app'; + +describe('module "database/query"', () => { + describe('class Query', () => { + let Comment: Class; + + class TestModel extends Model { + id: number; + body: string; + user: Class; + tags: Array>; + title: string; + comments: Array>; + isPublic: boolean; + reactions: Array>; + createdAt: Date; + updatedAt: Date; + + static tableName = 'posts'; + + static belongsTo = { + user: { + inverse: 'posts' + } + }; + + static hasMany = { + comments: { + inverse: 'post' + }, + + reactions: { + inverse: 'post' + }, + + tags: { + inverse: 'posts', + through: 'categorization' + } + }; + + static scopes = { + isPublic() { + return this.where({ + isPublic: true + }); + } + }; + } + + const assertItem = item => { + expect(item).to.be.an.instanceof(TestModel); + }; + + before(async () => { + const { store } = await getTestApp(); + + Comment = store.modelFor('comment'); + + await TestModel.initialize(store, () => { + return store.connection(TestModel.tableName); + }); + }); + + describe('.from()', () => { + let source; + + before(() => { + source = new Query(TestModel) + .limit(10) + .order('title', 'DESC') + .include( + 'user', + 'tags', + 'comments', + 'reactions' + ) + .where({ + isPublic: true + }); + }); + + it('creates a new `Query` from a source instance of `Query`', () => { + const result = Query.from(source); + + expect(result).to.not.equal(source); + expect(result.model).to.equal(source.model); + expect(result.isFind).to.equal(source.isFind); + expect(result.collection).to.equal(source.collection); + expect(result.shouldCount).to.equal(source.shouldCount); + expect(result.snapshots).to.deep.equal(source.snapshots); + expect(result.relationships).to.equal(source.relationships); + }); + }); + + describe('#all()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.all(); + + expect(result).to.equal(subject); + }); + + it('does not modify #snapshots', () => { + const result = subject.all(); + + expect(result.snapshots).to.be.an('array').with.lengthOf(0); + }); + + it('resolves with the correct array of `Model` instances', async () => { + const result = await subject.all(); + + expect(result).to.be.an('array').with.lengthOf(100); + + if (Array.isArray(result)) { + result.forEach(assertItem); + } + }); + }); + + describe('#not()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.not({ + isPublic: true + }); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.not({ + isPublic: true + }); + + expect(result.snapshots).to.deep.equal([ + ['whereNot', { 'posts.is_public': true }] + ]); + }); + + it('properly handles array conditions', () => { + const result = subject.not({ + id: [1, 2, 3], + isPublic: true + }); + + expect(result.snapshots).to.deep.equal([ + ['whereNotIn', ['posts.id', [1, 2, 3]]], + ['whereNot', { 'posts.is_public': true }] + ]); + }); + + it('resolves with the correct array of `Model` instances', async () => { + const result = await subject.not({ + isPublic: true + }); + + expect(result).to.be.an('array'); + + if (Array.isArray(result)) { + result.forEach(item => { + assertItem(item); + expect(item).to.have.property('isPublic', false); + }); + } + }); + }); + + describe('#find()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.find(1); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.find(1); + + expect(result.snapshots).to.deep.equal([ + ['where', { 'posts.id': 1 }], + ['limit', 1] + ]); + }); + + it('sets #isFind to `true`', () => { + const result = subject.find(1); + + expect(result.isFind).to.be.true; + }); + + it('sets #collection to `false`', () => { + const result = subject.find(1); + + expect(result.collection).to.be.false; + }); + + it('does not add a limit to #snapshots if #shouldCount', () => { + subject.shouldCount = true; + + const result = subject.find(1); + + expect(result.snapshots).to.deep.equal([ + ['where', { 'posts.id': 1 }] + ]); + }); + + it('resolves with the correct `Model` instance', async () => { + const result = await subject.find(1); + + expect(result) + .to.be.an.instanceof(TestModel) + .and.have.property('id', 1); + }); + }); + + describe('#page()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.page(2); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.page(2); + + expect(result.snapshots).to.deep.equal([ + ['limit', 25], + ['offset', 25] + ]); + }); + + it('does not modify #snapshots if #shouldCount', () => { + subject.shouldCount = true; + + const result = subject.page(2); + + expect(result.snapshots).to.have.lengthOf(0); + }); + + it('resolves with the correct array of `Model` instances', async () => { + const result = await subject.page(2); + + expect(result).to.be.an('array').with.lengthOf(25); + + if (Array.isArray(result)) { + result.forEach(assertItem); + } + }); + }); + + describe('#limit()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.limit(5); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.limit(5); + + expect(result.snapshots).to.deep.equal([ + ['limit', 5] + ]); + }); + + it('does not modify #snapshots if #shouldCount', () => { + subject.shouldCount = true; + + const result = subject.limit(5); + + expect(result.snapshots).to.have.lengthOf(0); + }); + + it('resolves with the correct array of `Model` instances', async () => { + const result = await subject.limit(5); + + expect(result).to.be.an('array').with.lengthOf(5); + + if (Array.isArray(result)) { + result.forEach(assertItem); + } + }); + }); + + describe('#order()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.order('id', 'DESC'); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.order('id', 'DESC'); + + expect(result.snapshots).to.deep.equal([ + ['orderBy', ['posts.id', 'DESC']] + ]); + }); + + it('defaults sort direction to `ASC`', () => { + const result = subject.order('id'); + + expect(result.snapshots).to.deep.equal([ + ['orderBy', ['posts.id', 'ASC']] + ]); + }); + + it('does not modify #snapshots if #shouldCount', () => { + subject.shouldCount = true; + + const result = subject.order('id', 'DESC'); + + expect(result.snapshots).to.have.lengthOf(0); + }); + + it('resolves with the correct array of `Model` instances', async () => { + const limit = 100; + const result = await subject.order('id', 'DESC'); + + expect(result).to.be.an('array').with.lengthOf(limit); + + if (Array.isArray(result)) { + result.forEach((item, index) => { + assertItem(item); + expect(item).to.have.property('id', limit - index); + }); + } + }); + }); + + describe('#where()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.where({ + isPublic: true + }); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.where({ + isPublic: true + }); + + expect(result.snapshots).to.deep.equal([ + ['where', { 'posts.is_public': true }] + ]); + }); + + it('properly handles array conditions', () => { + const result = subject.where({ + id: [1, 2, 3], + isPublic: true + }); + + expect(result.snapshots).to.deep.equal([ + ['whereIn', ['posts.id', [1, 2, 3]]], + ['where', { 'posts.is_public': true }] + ]); + }); + + it('resolves with the correct array of `Model` instances', async () => { + const result = await subject.where({ + isPublic: true + }); + + expect(result).to.be.an('array'); + + if (Array.isArray(result)) { + result.forEach(item => { + assertItem(item); + expect(item).to.have.property('isPublic', true); + }); + } + }); + }); + + describe('#first()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.first(); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.first(); + + expect(result.snapshots).to.deep.equal([ + ['orderBy', ['posts.id', 'ASC']], + ['limit', 1] + ]); + }); + + it('sets #collection to `false`', () => { + const result = subject.first(); + + expect(result.collection).to.be.false; + }); + + it('respects order if one already exists', () => { + const result = subject.order('createdAt', 'DESC').first(); + + expect(result.snapshots).to.deep.equal([ + ['orderBy', ['posts.created_at', 'DESC']], + ['limit', 1] + ]); + }); + + it('does not modify #snapshots if #shouldCount', () => { + subject.shouldCount = true; + + const result = subject.first(); + + expect(result.snapshots).to.have.lengthOf(0); + }); + + it('resolves with the correct `Model` instance', async () => { + const result = await subject.first(); + + expect(result) + .to.be.an.instanceof(TestModel) + .and.have.property('id', 1); + }); + }); + + describe('#last()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.last(); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.last(); + + expect(result.snapshots).to.deep.equal([ + ['orderBy', ['posts.id', 'DESC']], + ['limit', 1] + ]); + }); + + it('sets #collection to `false`', () => { + const result = subject.last(); + + expect(result.collection).to.be.false; + }); + + it('respects order if one already exists', () => { + const result = subject.order('createdAt', 'DESC').last(); + + expect(result.snapshots).to.deep.equal([ + ['orderBy', ['posts.created_at', 'DESC']], + ['limit', 1] + ]); + }); + + it('does not modify #snapshots if #shouldCount', () => { + subject.shouldCount = true; + + const result = subject.last(); + + expect(result.snapshots).to.have.lengthOf(0); + }); + + it('resolves with the correct `Model` instance', async () => { + const result = await subject.last(); + + expect(result) + .to.be.an.instanceof(TestModel) + .and.have.property('id', 100); + }); + }); + + describe('#count()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.count(); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.count(); + + expect(result.snapshots).to.deep.equal([ + ['count', '* as countAll'] + ]); + }); + + it('sets #shouldCount to `true`', () => { + const result = subject.count(); + + expect(result).to.have.property('shouldCount', true); + }); + + it('removes all snapshots except for filter conditions', () => { + const result = subject + .limit(1) + .offset(50) + .order('createdAt') + .where({ isPublic: true }) + .count(); + + expect(result.snapshots).to.deep.equal([ + ['count', '* as countAll'], + ['where', { 'posts.is_public': true }] + ]); + }); + + it('resolves with the number of matching records', async () => { + const result = await subject.count(); + + expect(result).to.equal(100); + }); + }); + + describe('#offset()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.offset(10); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.offset(10); + + expect(result.snapshots).to.deep.equal([ + ['offset', 10] + ]); + }); + + it('does not modify #snapshots if #shouldCount', () => { + subject.shouldCount = true; + + const result = subject.offset(10); + + expect(result.snapshots).to.have.lengthOf(0); + }); + + it('resolves with the correct array of `Model` instances', async () => { + const result = await subject.offset(10); + + expect(result).to.be.an('array').with.lengthOf(90); + + if (Array.isArray(result)) { + result.forEach(assertItem); + } + }); + }); + + describe('#select()', () => { + let subject; + const attrs = ['id', 'title', 'createdAt']; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.select(...attrs); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.select(...attrs); + + expect(result.snapshots).to.deep.equal([ + ['select', [ + 'posts.id AS id', + 'posts.title AS title', + 'posts.created_at AS createdAt' + ]] + ]); + }); + + it('resolves with the correct array of `Model` instances', async () => { + const result = await subject.select(...attrs); + + expect(result).to.be.an('array'); + + if (Array.isArray(result)) { + result.forEach(item => { + assertItem(item); + expect(item.rawColumnData).to.have.all.keys(attrs); + }); + } + }); + }); + + describe('#distinct()', () => { + let subject; + const attrs = ['title']; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.distinct(...attrs); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots', () => { + const result = subject.distinct(...attrs); + + expect(result.snapshots).to.deep.equal([ + ['distinct', ['posts.title AS title']], + ['select', []] + ]); + }); + + it('resolves with the correct array of `Model` instances', async () => { + const result = await subject.distinct(...attrs); + + expect(result).to.be.an('array'); + + if (Array.isArray(result)) { + result.forEach(item => { + assertItem(item); + expect(item.rawColumnData).to.have.all.keys(attrs); + }); + } + }); + }); + + describe('#include()', () => { + let subject; + + const assertRelationships = (relationships, attrs = [ + 'id', + 'message', + 'edited', + 'userId', + 'postId', + 'createdAt', + 'updatedAt', + 'postId' + ]) => { + const { comments } = relationships; + + expect(relationships).to.have.property('comments'); + + expect(comments).to.have.all.keys([ + 'attrs', + 'type', + 'model', + 'through', + 'foreignKey' + ]); + + expect(comments).to.have.property('type', 'hasMany'); + expect(comments).to.have.property('model', Comment); + expect(comments).to.have.property('through', undefined); + expect(comments).to.have.property('foreignKey', 'post_id'); + + expect(comments) + .to.have.property('attrs') + .and.include.all.members(attrs); + }; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.include('user', 'comments'); + + expect(result).to.equal(subject); + }); + + it('properly modifies #snapshots when using an array of strings', () => { + const { + snapshots, + relationships + } = subject.include('user', 'comments'); + + expect(snapshots) + .to.have.deep.property('[0][0]', 'includeSelect'); + + expect(snapshots) + .to.have.deep.property('[0][1]') + .and.include.all.members([ + 'users.id AS user.id', + 'users.name AS user.name', + 'users.email AS user.email', + 'users.password AS user.password', + 'users.created_at AS user.createdAt', + 'users.updated_at AS user.updatedAt' + ]); + + expect(snapshots) + .to.have.deep.property('[1][0]', 'leftOuterJoin'); + + expect(snapshots) + .to.have.deep.property('[1][1]') + .and.include.all.members([ + 'users', + 'posts.user_id', + '=', + 'users.id' + ]); + + assertRelationships(relationships); + }); + + it('properly modifies #snapshots when using an object', () => { + const params = { + user: [ + 'id', + 'name' + ], + comments: [ + 'id', + 'name', + 'edited', + 'updatedAt' + ] + }; + + const { snapshots, relationships } = subject.include(params); + + expect(snapshots) + .to.have.deep.property('[0][0]', 'includeSelect'); + + expect(snapshots) + .to.have.deep.property('[0][1]') + .and.include.all.members([ + 'users.id AS user.id', + 'users.name AS user.name' + ]); + + expect(snapshots) + .to.have.deep.property('[1][0]', 'leftOuterJoin'); + + expect(snapshots) + .to.have.deep.property('[1][1]') + .and.include.all.members([ + 'users', + 'posts.user_id', + '=', + 'users.id' + ]); + + assertRelationships(relationships, params.comments); + }); + + it('resolves with the correct array of `Model` instances', async () => { + const result = await subject.include('user', 'comments'); + + expect(result).to.be.an('array'); + + if (Array.isArray(result)) { + result.forEach(assertItem); + } + }); + }); + + describe('#scope()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('can be chained to other query methods', () => { + const result = subject + .isPublic() + .select('id', 'title') + .limit(10); + + expect(result.snapshots).to.deep.equal([ + ['where', { 'posts.is_public': true }, 'isPublic'], + ['select', ['posts.id AS id', 'posts.title AS title']], + ['limit', 10] + ]); + }); + + it('can be chained from other query methods', () => { + const result = subject + .all() + .select('id', 'title') + .limit(10) + .isPublic(); + + expect(result.snapshots).to.deep.equal([ + ['select', ['posts.id AS id', 'posts.title AS title']], + ['limit', 10], + ['where', { 'posts.is_public': true }, 'isPublic'] + ]); + }); + }); + + describe('#unscope()', () => { + let subject; + + beforeEach(() => { + subject = new Query(TestModel); + }); + + it('returns `this`', () => { + const result = subject.isPublic().unscope('isPublic'); + + expect(result).to.equal(subject); + }); + + it('removes a named scope from #snapshots', () => { + const result = subject + .select('id', 'title') + .isPublic() + .limit(10) + .unscope('isPublic'); + + expect(result.snapshots).to.deep.equal([ + ['select', ['posts.id AS id', 'posts.title AS title']], + ['limit', 10] + ]); + }); + }); + }); +}); diff --git a/src/packages/database/test/validation.test.js b/src/packages/database/test/validation.test.js new file mode 100644 index 00000000..94d97725 --- /dev/null +++ b/src/packages/database/test/validation.test.js @@ -0,0 +1,33 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import Validation from '../validation'; + +describe('module "database/validation"', () => { + describe('class Validation', () => { + describe('#isValid()', () => { + const validator = (value = '') => value.length >= 8; + + it('returns true when constraints are met', () => { + const subject = new Validation({ + validator, + key: 'password', + value: 'super-secret-password' + }); + + expect(subject.isValid()).to.be.true; + }); + + it('returns false when constraints are not met', () => { + const subject = new Validation({ + validator, + key: 'password', + value: 'pwd' + }); + + expect(subject.isValid()).to.be.false; + }); + }); + }); +}); diff --git a/src/packages/database/model/errors/index.js b/src/packages/database/validation/errors/index.js similarity index 100% rename from src/packages/database/model/errors/index.js rename to src/packages/database/validation/errors/index.js diff --git a/src/packages/database/model/errors/validation-error.js b/src/packages/database/validation/errors/validation-error.js similarity index 100% rename from src/packages/database/model/errors/validation-error.js rename to src/packages/database/validation/errors/validation-error.js diff --git a/src/packages/database/validation/index.js b/src/packages/database/validation/index.js index ef202f50..8e5cf42a 100644 --- a/src/packages/database/validation/index.js +++ b/src/packages/database/validation/index.js @@ -1,15 +1,15 @@ // @flow -import type { Validation$Validator, Validation$opts } from './interfaces'; +import type { Validation$opts } from './interfaces'; /** * @private */ -class Validation { +class Validation { key: string; - value: mixed; + value: T; - validator: Validation$Validator; + validator: (value?: T) => boolean; constructor(opts: Validation$opts) { Object.defineProperties(this, { @@ -36,9 +36,10 @@ class Validation { }); } - isValid() { + isValid(): boolean { return this.validator(this.value); } } export default Validation; +export { ValidationError } from './errors'; diff --git a/src/packages/database/validation/interfaces.js b/src/packages/database/validation/interfaces.js index eed911b7..ab8f6b8f 100644 --- a/src/packages/database/validation/interfaces.js +++ b/src/packages/database/validation/interfaces.js @@ -1,8 +1,6 @@ // @flow -export type Validation$Validator = (value: mixed) => boolean; - -export type Validation$opts = { - key: string, - value: mixed, - validator: T +export type Validation$opts = { + key: string; + value: T; + validator: (value?: T) => boolean; }; diff --git a/src/packages/freezeable/test/freezeable.test.js b/src/packages/freezeable/test/freezeable.test.js new file mode 100644 index 00000000..67e7ef5d --- /dev/null +++ b/src/packages/freezeable/test/freezeable.test.js @@ -0,0 +1,160 @@ +// @flow +import { expect } from 'chai'; +import { it, describe, before, beforeEach } from 'mocha'; + +import { FreezeableMap, FreezeableSet } from '../index'; + +describe('module "freezeable"', () => { + describe('class FreezeableMap', () => { + describe('#constructor()', () => { + let subject; + + before(() => { + subject = new FreezeableMap([ + ['a', 1], + ['b', 2], + ['c', 3] + ]); + }); + + it('returns a mutable `Map` interface', () => { + expect(subject.size).to.equal(3); + + subject.clear(); + expect(subject.size).to.equal(0); + + subject.set('a', 1).set('b', 2).set('c', 3); + expect(subject.size).to.equal(3); + + subject.set('d', 4); + expect(subject.size).to.equal(4); + + subject.delete('d'); + expect(subject.size).to.equal(3); + }); + }); + + describe('#freeze()', () => { + let subject; + + beforeEach(() => { + const d = { + a: 1, + b: 2, + c: 3 + }; + + subject = new FreezeableMap([ + ['a', 1], + ['b', 2], + ['c', 3], + ['d', d] + ]); + }); + + it('returns `this`', () => { + expect(subject.freeze()).to.equal(subject); + }); + + it('is immutable after #freeze is called', () => { + subject.freeze(); + + subject.clear(); + expect(subject.size).to.equal(4); + + subject.set('a', 1).set('b', 2).set('c', 3); + expect(subject.size).to.equal(4); + + subject.set('d', 4); + expect(subject.size).to.equal(4); + + subject.delete('d'); + expect(subject.size).to.equal(4); + + expect(subject.get('d')).to.not.be.frozen; + }); + + it('can recursively freeze members when `deep = true`', () => { + subject.freeze(true); + expect(subject.get('d')).to.be.frozen; + }); + }); + }); + + describe('class FreezeableSet', () => { + describe('#constructor()', () => { + let subject; + + before(() => { + subject = new FreezeableSet([1, 2, 3]); + }); + + it('returns a mutable `Set` interface', () => { + expect(subject.size).to.equal(3); + + subject.clear(); + expect(subject.size).to.equal(0); + + subject.add(1).add(2).add(3); + expect(subject.size).to.equal(3); + + subject.add(4); + expect(subject.size).to.equal(4); + + subject.delete(4); + expect(subject.size).to.equal(3); + }); + }); + + describe('#freeze()', () => { + let subject; + + const obj = { + a: 1, + b: 2, + c: 3 + }; + + beforeEach(() => { + subject = new FreezeableSet([1, 2, 3, obj]); + }); + + it('returns `this`', () => { + expect(subject.freeze()).to.equal(subject); + }); + + it('is immutable after #freeze is called', () => { + subject.freeze(); + + expect(subject.size).to.equal(4); + + subject.clear(); + expect(subject.size).to.equal(4); + + subject.add(1).add(2).add(3); + expect(subject.size).to.equal(4); + + subject.add(4); + expect(subject.size).to.equal(4); + + subject.delete(4); + expect(subject.size).to.equal(4); + + subject.forEach(member => { + if (typeof member === 'object') { + expect(member).to.not.be.frozen; + } + }); + }); + + it('can recursively freeze members when `deep = true`', () => { + subject.freeze(true); + subject.forEach(member => { + if (typeof member === 'object') { + expect(member).to.be.frozen; + } + }); + }); + }); + }); +}); diff --git a/src/packages/fs/test/create-resolver.test.js b/src/packages/fs/test/create-resolver.test.js new file mode 100644 index 00000000..62d0cf80 --- /dev/null +++ b/src/packages/fs/test/create-resolver.test.js @@ -0,0 +1,30 @@ +// @flow + +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import createResolver from '../utils/create-resolver'; + +describe('module "fs"', () => { + describe('util createResolver()', () => { + + const RESOLVED_VALUE = 'fs#createResolver resolved'; + const REJECTED_VALUE = new Error('fs#createResolver error'); + + it('resolves a promise on callback execution', async () => { + const deferred = await new Promise((resolve, reject) => { + const resolver = createResolver(resolve, reject); + resolver(null, RESOLVED_VALUE); + }); + expect(deferred).to.equal(RESOLVED_VALUE); + }); + + it('rejects a promise on callback execution with an err arg', async () => { + const deferred = await new Promise((resolve, reject) => { + const resolver = createResolver(resolve, reject); + resolver(REJECTED_VALUE, RESOLVED_VALUE); + }).catch(err => err); + expect(deferred).to.equal(REJECTED_VALUE); + }); + }); +}); diff --git a/src/packages/fs/test/exists.test.js b/src/packages/fs/test/exists.test.js new file mode 100644 index 00000000..98a1a798 --- /dev/null +++ b/src/packages/fs/test/exists.test.js @@ -0,0 +1,49 @@ +// @flow + +import { expect } from 'chai'; +import { it, describe, before, after } from 'mocha'; + +import { sep, basename, dirname, join } from 'path'; + +import { + createTmpDir, + createTmpFiles, + removeTmpDir +} from './utils'; + +import { exists } from '../index'; + +const TMP_PATH = join(sep, 'tmp', `lux-${Date.now()}`); + +describe('module "fs"', () => { + describe('#exists()', () => { + + before(async () => { + await createTmpDir(TMP_PATH); + await createTmpFiles(TMP_PATH, 5); + }); + + it('is true if "PATH" exists', async () => { + expect(await exists(TMP_PATH)).to.be.true; + }); + + it('is false if "PATH" does not exist', async () => { + const emptyPath = join(dirname(TMP_PATH), 'does-not-exist.tmp'); + expect(await exists(emptyPath)).to.be.false; + }); + + it('is true if regexp "PATH" exists within "DIR"', async () => { + const pathRegexp = new RegExp(basename(TMP_PATH)); + const fileExists = await exists(pathRegexp, dirname(TMP_PATH)); + expect(fileExists).to.be.true; + }); + + it('is false if regexp "PATH" does not exist within "DIR"', async () => { + const emptyRegexp = new RegExp('does-not-exist.tmp'); + const fileExists = await exists(emptyRegexp, dirname(TMP_PATH)); + expect(fileExists).to.be.false; + }); + + after(() => removeTmpDir(TMP_PATH)); + }); +}); diff --git a/src/packages/fs/test/fs.test.js b/src/packages/fs/test/fs.test.js new file mode 100644 index 00000000..ab0c6a6e --- /dev/null +++ b/src/packages/fs/test/fs.test.js @@ -0,0 +1,172 @@ +// @flow + +import { expect } from 'chai'; +import { it, describe, before, after, beforeEach, afterEach } from 'mocha'; +import { join } from 'path'; +import { spy } from 'sinon'; + +import { createTmpDir, getTmpFile, createTmpFiles } from './utils'; +import * as fs from '../index'; + +import type { Spy } from 'sinon'; + +describe('module "fs"', () => { + + let spies: {[ module: string ]: Spy } = {}; + let tmpDirPath: string; + const spiedMethods = [ + 'mkdir', + 'rmdir', + 'readdir', + 'readFile', + 'writeFile', + 'appendFile', + 'stat', + 'unlink', + ]; + + before(() => { + // wrap node fs methods in spies to test delegation + const nativeFs = require('fs'); + spies = spiedMethods.reduce((memo, methodName) => { + memo[methodName] = spy(nativeFs, methodName); + return memo; + }, spies); + }); + + after(() => { + // unwrap spies of node fs methods + spies = spiedMethods.reduce((memo, methodName) => { + memo[methodName].restore(); + Reflect.deleteProperty(memo, methodName); + return memo; + }, spies); + }); + + beforeEach(async () => { + tmpDirPath = `/tmp/lux-${Date.now()}`; + await createTmpDir(tmpDirPath); + }); + + afterEach(async () => { + if (tmpDirPath) { + await fs.rmrf(tmpDirPath); + spiedMethods.forEach((methodName) => { + spies[methodName].reset(); + }); + } + }); + + describe('#mkdir', () => { + it('delegates to node fs#mkdir', async () => { + const dirPath = join(tmpDirPath, 'test-mkdir'); + await fs.mkdir(dirPath); + expect(spies['mkdir'].calledWith(dirPath)).to.be.true; + }); + it('returns a promise', () => { + const dirPath = join(tmpDirPath, 'test-mkdir'); + returnsPromiseSpec('mkdir', dirPath)(); + }); + }); + + describe('#rmdir', () => { + it('delegates to node fs#rmdir', async () => { + await fs.rmdir(tmpDirPath); + expect(spies['rmdir'].calledWith(tmpDirPath)).to.be.true; + }); + it('returns a promise', returnsPromiseSpec('rmdir', tmpDirPath)); + }); + + describe('#readdir', () => { + it('delegates to node fs#readdir', async () => { + await fs.readdir(tmpDirPath); + expect(spies['readdir'].calledWith(tmpDirPath)).to.be.true; + }); + it('returns a promise', returnsPromiseSpec('readdir', tmpDirPath)); + }); + + describe('#readFile', () => { + let tmpFilePath: string; + + beforeEach(async () => { + await createTmpFiles(tmpDirPath, 5); + tmpFilePath = await getTmpFile(tmpDirPath); + }); + + it('delegates to node fs#readFile', async () => { + await fs.readFile(tmpFilePath); + expect(spies['readFile'].calledWith(tmpFilePath)).to.be.true; + }); + it('returns a promise', returnsPromiseSpec('readFile', tmpFilePath)); + }); + + describe('#writeFile', () => { + let tmpFilePath: string; + + beforeEach(async () => { + await createTmpFiles(tmpDirPath, 5); + tmpFilePath = await getTmpFile(tmpDirPath); + }); + + it('delegates to node fs#writeFile', async () => { + await fs.writeFile(tmpFilePath, 'test data'); + expect(spies['writeFile'].calledWith(tmpFilePath)).to.be.true; + }); + it('returns a promise', returnsPromiseSpec('writeFile', tmpFilePath)); + }); + + describe('#appendFile', () => { + let tmpFilePath: string; + + beforeEach(async () => { + await createTmpFiles(tmpDirPath, 5); + tmpFilePath = await getTmpFile(tmpDirPath); + }); + + it('delegates to node fs#appendFile', async () => { + await fs.appendFile(tmpFilePath, 'test data'); + expect(spies['appendFile'].calledWith(tmpFilePath)); + }); + it('returns a promise', returnsPromiseSpec('appendFile', tmpFilePath)); + }); + + describe('#stat', () => { + let tmpFilePath: string; + + beforeEach(async () => { + await createTmpFiles(tmpDirPath, 5); + tmpFilePath = await getTmpFile(tmpDirPath); + }); + + it('delegates to node fs#stat', async () => { + await fs.stat(tmpFilePath); + expect(spies['stat'].calledWith(tmpFilePath)); + }); + it('returns a promise', returnsPromiseSpec('stat', tmpFilePath)); + }); + + describe('#unlink', () => { + let tmpFilePath: string; + + beforeEach(async () => { + await createTmpFiles(tmpDirPath, 5); + tmpFilePath = await getTmpFile(tmpDirPath); + }); + + it('delegates to node fs#unlink', async () => { + await fs.unlink(tmpFilePath); + expect(spies['unlink'].calledWith(tmpFilePath)); + }); + it('returns a promise', returnsPromiseSpec('unlink', tmpFilePath)); + }); +}); + +function returnsPromiseSpec( + method: string, + ...args: Array +): (done?: () => void) => void | Promise { + return function () { + const res = Reflect.apply(fs[method], fs, args); + expect(res).to.be.an.instanceOf(Promise); + }; +} diff --git a/src/packages/fs/test/is-js-file.test.js b/src/packages/fs/test/is-js-file.test.js new file mode 100644 index 00000000..d99870c6 --- /dev/null +++ b/src/packages/fs/test/is-js-file.test.js @@ -0,0 +1,27 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import { isJSFile } from '../index'; + +describe('module "fs"', () => { + describe('#isJSFile()', () => { + const [a, b, c] = [ + 'author.js', + 'author.rb', + '.gitkeep' + ]; + + it('is true if a file has a `.js` extension', () => { + expect(isJSFile(a)).to.be.true; + }); + + it('is false if a file does not have a `.js` extension', () => { + expect(isJSFile(b)).to.be.false; + }); + + it('is false if the file is prefixed with `.`', () => { + expect(isJSFile(c)).to.be.false; + }); + }); +}); diff --git a/src/packages/fs/test/rmrf.test.js b/src/packages/fs/test/rmrf.test.js new file mode 100644 index 00000000..7416622e --- /dev/null +++ b/src/packages/fs/test/rmrf.test.js @@ -0,0 +1,44 @@ +// @flow + +import { expect } from 'chai'; +import { it, describe, beforeEach, afterEach } from 'mocha'; + +import { sep, join } from 'path'; + +import { rmrf, exists } from '../index'; +import { + getTmpFile, + createTmpDir, + removeTmpDir, + createTmpFiles +} from './utils'; + +describe('module "fs"', () => { + describe('#rmrf()', () => { + let tmpDirPath: string; + + beforeEach(async () => { + tmpDirPath = join(sep, 'tmp', `lux-${Date.now()}`); + + await createTmpDir(tmpDirPath); + await createTmpFiles(tmpDirPath, 5); + }); + + it('removes a file', async () => { + const tmpFilePath = await getTmpFile(tmpDirPath); + await rmrf(tmpFilePath); + expect(await exists(tmpFilePath)).to.be.false; + }); + + it('removes a directory and its contents', async () => { + await rmrf(tmpDirPath); + expect(await exists(tmpDirPath)).to.be.false; + }); + + afterEach(async () => { + if (await exists(tmpDirPath)) { + await removeTmpDir(tmpDirPath); + } + }); + }); +}); diff --git a/src/packages/fs/test/utils/create-tmp-dir.js b/src/packages/fs/test/utils/create-tmp-dir.js new file mode 100644 index 00000000..c18a4b53 --- /dev/null +++ b/src/packages/fs/test/utils/create-tmp-dir.js @@ -0,0 +1,22 @@ +// @flow +import { mkdir } from 'fs'; +import { sep, join, dirname } from 'path'; + +export default function createTmpDir(path: string) { + return createRootTmpDir(path) + .then(() => new Promise((resolve, reject) => { + mkdir(path, undefined, (err) => { + if (err) return reject(err); + resolve(); + }); + })); +} + + +function createRootTmpDir() { + return new Promise(resolve => { + const path = join(sep, 'tmp'); + + mkdir(dirname(path), undefined, () => resolve(path)); + }); +} diff --git a/src/packages/fs/test/utils/create-tmp-files.js b/src/packages/fs/test/utils/create-tmp-files.js new file mode 100644 index 00000000..141b455e --- /dev/null +++ b/src/packages/fs/test/utils/create-tmp-files.js @@ -0,0 +1,22 @@ +// @flow + +import { writeFile } from 'fs'; +import { join } from 'path'; + +import range from '../../../../utils/range'; + +export default function createTmpFiles( + dir: string, + numberToCreate: number +) { + const filePaths = Array.from(range(1, numberToCreate)) + .map(() => join(dir, `${Date.now()}.tmp`)); + return Promise.all(filePaths.map((filePath) => { + return new Promise((resolve, reject) => { + writeFile(filePath, '', (error) => { + if (error) return reject(error); + resolve(); + }); + }); + })); +} diff --git a/src/packages/fs/test/utils/get-tmp-file.js b/src/packages/fs/test/utils/get-tmp-file.js new file mode 100644 index 00000000..1aa0f519 --- /dev/null +++ b/src/packages/fs/test/utils/get-tmp-file.js @@ -0,0 +1,13 @@ +// @flow + +import { readdir } from 'fs'; +import { join } from 'path'; + +export default function getTmpFile (path: string) { + return new Promise((resolve, reject) => { + readdir(path, (err, files) => { + if (err) return reject(err); + resolve(join(path, files[0])); + }); + }); +} diff --git a/src/packages/fs/test/utils/index.js b/src/packages/fs/test/utils/index.js new file mode 100644 index 00000000..41f71c70 --- /dev/null +++ b/src/packages/fs/test/utils/index.js @@ -0,0 +1,4 @@ +export { default as createTmpDir } from './create-tmp-dir'; +export { default as removeTmpDir } from './remove-tmp-dir'; +export { default as getTmpFile } from './get-tmp-file'; +export { default as createTmpFiles } from './create-tmp-files'; diff --git a/src/packages/fs/test/utils/remove-tmp-dir.js b/src/packages/fs/test/utils/remove-tmp-dir.js new file mode 100644 index 00000000..bc7d843d --- /dev/null +++ b/src/packages/fs/test/utils/remove-tmp-dir.js @@ -0,0 +1,32 @@ +// @flow + +import { rmdir, readdir, unlink } from 'fs'; +import { join } from 'path'; + +export default function removeTmpDir(path: string) { + return new Promise((resolve, reject) => { + readdir(path, (err, files) => { + if (err) return reject(err); + const filePaths = files.map(fileName => join(path, fileName)); + removeTmpFiles(filePaths) + .then(() => { + rmdir(path, (error) => { + if (error) reject(error); + resolve(); + }); + }) + .catch((error) => reject(error)); + }); + }); +} + +function removeTmpFiles(filePaths: Array) { + return Promise.all(filePaths.map((filePath) => { + return new Promise((resolve, reject) => { + unlink(filePath, (err) => { + if (err) return reject(err); + resolve(); + }); + }); + })); +} diff --git a/src/packages/jsonapi/test/has-media-type.test.js b/src/packages/jsonapi/test/has-media-type.test.js new file mode 100644 index 00000000..543945ee --- /dev/null +++ b/src/packages/jsonapi/test/has-media-type.test.js @@ -0,0 +1,17 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import { hasMediaType } from '../index'; + +describe('module "jsonapi"', () => { + describe('#hasMediaType()', () => { + it('is true if mime type does specify a media type', () => { + expect(hasMediaType('application/vnd.api+json;charset=utf8')).to.be.true; + }); + + it('is false if mime type does not specify a media type', () => { + expect(hasMediaType('application/vnd.api+json')).to.be.false; + }); + }); +}); diff --git a/src/packages/jsonapi/test/is-jsonapi.test.js b/src/packages/jsonapi/test/is-jsonapi.test.js new file mode 100644 index 00000000..efb678ce --- /dev/null +++ b/src/packages/jsonapi/test/is-jsonapi.test.js @@ -0,0 +1,18 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import { isJSONAPI } from '../index'; + +describe('module "jsonapi"', () => { + describe('#isJSONAPI()', () => { + it('is true if mime type matches application/vnd.api+json', () => { + expect(isJSONAPI('application/vnd.api+json')).to.be.true; + expect(isJSONAPI('application/vnd.api+json;charset=utf8')).to.be.true; + }); + + it('is false if mime type does not match application/vnd.api+json', () => { + expect(isJSONAPI('application/json')).to.be.false; + }); + }); +}); diff --git a/src/packages/loader/index.js b/src/packages/loader/index.js index 9dc24ac0..3b1db4e8 100644 --- a/src/packages/loader/index.js +++ b/src/packages/loader/index.js @@ -25,4 +25,8 @@ export { getParentKey as getNamespaceKey } from './resolver'; -export type { Bundle$Namespace, Bundle$NamespaceGroup, } from './interfaces'; +export type { + Loader, + Bundle$Namespace, + Bundle$NamespaceGroup +} from './interfaces'; diff --git a/src/packages/loader/test/format-key.test.js b/src/packages/loader/test/format-key.test.js new file mode 100644 index 00000000..771078d6 --- /dev/null +++ b/src/packages/loader/test/format-key.test.js @@ -0,0 +1,18 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import formatKey from '../utils/format-key'; + +describe('module "loader"', () => { + describe('#formatKey()', () => { + it('converts a key to kebab-case', () => { + expect(formatKey('someKey')).to.equal('some-key'); + expect(formatKey('some_key')).to.equal('some-key'); + }); + + it('can execute a formatter function on a key', () => { + expect(formatKey('key', key => `some_${key}`)).to.equal('some-key'); + }); + }); +}); diff --git a/src/packages/loader/test/loader.test.js b/src/packages/loader/test/loader.test.js new file mode 100644 index 00000000..9a132c68 --- /dev/null +++ b/src/packages/loader/test/loader.test.js @@ -0,0 +1,91 @@ +// @flow +import { expect } from 'chai'; +import { it, before, describe } from 'mocha'; + +import { FreezeableMap } from '../../freezeable'; +import { createLoader } from '../index'; + +import { getTestApp } from '../../../../test/utils/get-test-app'; + +import type Application from '../../application'; +import type { Loader } from '../index'; + +describe('module "loader"', () => { + let app: Application; + + before(async () => { + app = await getTestApp(); + }); + + describe('#createLoader()', () => { + let subject: Loader; + + before(() => { + subject = createLoader(app.path); + }); + + it('can create a loader function', () => { + expect(subject).to.be.a('function').and.have.lengthOf(1); + }); + + it('can load an Application', () => { + expect(subject('application')).to.be.equal(app.constructor); + }); + + it('can load a config object', () => { + expect(subject('config')).to.be.an.object; + }); + + it('can load Controllers', () => { + const result = subject('controllers'); + + expect(result).to.be.an.instanceof(FreezeableMap); + + result.forEach(value => { + expect( + Reflect.getPrototypeOf(value).name.endsWith('Controller') + ).to.be.true; + }); + }); + + it('can load Migrations', () => { + const result = subject('migrations'); + + expect(result).to.be.an.instanceof(FreezeableMap); + + result.forEach(value => { + expect(value.constructor.name).to.equal('Migration'); + }); + }); + + it('can load Models', () => { + const result = subject('models'); + + expect(result).to.be.an.instanceof(FreezeableMap); + + result.forEach(value => { + expect(Reflect.getPrototypeOf(value).name).to.equal('Model'); + }); + }); + + it('can load a routes function', () => { + expect(subject('routes')).to.be.a('function'); + }); + + it('can load a database seed function', () => { + expect(subject('seed')).to.be.a('function'); + }); + + it('can load Serializers', () => { + const result = subject('serializers'); + + expect(result).to.be.an.instanceof(FreezeableMap); + + result.forEach(value => { + expect( + Reflect.getPrototypeOf(value).name.endsWith('Serializer') + ).to.be.true; + }); + }); + }); +}); diff --git a/src/packages/logger/test/filter-params.test.js b/src/packages/logger/test/filter-params.test.js new file mode 100644 index 00000000..466dadd2 --- /dev/null +++ b/src/packages/logger/test/filter-params.test.js @@ -0,0 +1,41 @@ +// @flow + +import { expect } from 'chai'; +import { it, describe, before } from 'mocha'; + +import filterParams from '../request-logger/utils/filter-params'; + +describe('module "logger"', () => { + describe('util filterParams()', () => { + let params; + let filter; + before(() => { + params = { + id: 1, + username: 'test', + password: 'test' + }; + filter = ['username', 'password']; + }); + + it('replaces the value of filtered params', () => { + const filtered = filterParams(params, ...filter); + expect(filtered.username).to.not.equal(params.username); + expect(filtered.password).to.not.equal(params.password); + }); + + it('leaves non-filtered params unchanged', () => { + const filtered = filterParams(params, ...filter); + expect(filtered.id).to.equal(params.id); + }); + + it('handles nested parameters', () => { + const nestedParams = { params }; + const filtered = filterParams(nestedParams, ...filter); + expect(filtered.params.username) + .to.not.equal(nestedParams.params.username); + expect(filtered.params.password) + .to.not.equal(nestedParams.params.password); + }); + }); +}); diff --git a/src/packages/logger/test/logger.test.js b/src/packages/logger/test/logger.test.js new file mode 100644 index 00000000..83337364 --- /dev/null +++ b/src/packages/logger/test/logger.test.js @@ -0,0 +1,132 @@ +// @flow + +import { expect } from 'chai'; +import { it, describe, before, afterEach } from 'mocha'; + +import Logger, { line } from '../index'; + +const TEST_MESSAGE = 'test'; + +describe('module "logger"', () => { + describe('class Logger', () => { + let jsonLogger: Logger; + let disabledLogger: Logger; + let unhookWrite: ?() => void; + + before(async () => { + const baseConfig = { + level: 'INFO', + format: 'json', + enabled: true, + filter: { params: [] } + }; + jsonLogger = new Logger(baseConfig); + const disabledConfig = Object.assign({}, baseConfig, { enabled: false }); + disabledLogger = new Logger(disabledConfig); + }); + + afterEach(() => { + if (unhookWrite) { + unhookWrite(); + unhookWrite = null; + } + }); + + it('writes to stdout at the logger level', (done) => { + unhookWrite = hookWrite((line) => { + const { message, level } = JSON.parse(line); + expect(message).to.equal(TEST_MESSAGE); + expect(level).to.equal('INFO'); + done(); + }); + jsonLogger.info(TEST_MESSAGE); + }); + + it('does write messages above the logger level', (done) => { + unhookWrite = hookWrite((line) => { + const { message, level } = JSON.parse(line); + expect(message).to.equal(TEST_MESSAGE); + expect(level).to.equal('WARN'); + done(); + }); + jsonLogger.warn(TEST_MESSAGE); + }); + + it('does not write messages below the logger level', (done) => { + unhookWrite = hookWrite(() => { + done(new Error('Should not log message of lower level.')); + }); + jsonLogger.debug(TEST_MESSAGE); + setTimeout(() => done(), 50); + }); + + it('writes with a recent timestamp', (done) => { + const oldTimestamp = Date.now(); + unhookWrite = hookWrite((line) => { + const { timestamp } = JSON.parse(line); + expect(Date.parse(timestamp)).to.equal(oldTimestamp); + done(); + }); + jsonLogger.info(TEST_MESSAGE); + }); + + it('writes json', (done) => { + unhookWrite = hookWrite((line) => { + line = line.trim(); + expect(JSON.stringify(JSON.parse(line))).to.equal(line); + done(); + }); + jsonLogger.info(TEST_MESSAGE); + }); + + it('does not write when disabled', (done) => { + unhookWrite = hookWrite(() => { + done(new Error('Logger should not write when disabled')); + }); + disabledLogger.info(TEST_MESSAGE); + setTimeout(() => done(), 50); + }); + }); + + describe('#line()', () => { + it('returns a single line string from a multi-line string', () => { + expect(line` + this + is + a + test + `).to.equal('this is a test'); + }); + }); +}); + +function hookWrite (cb) { + const oldStdoutWrite = process.stdout.write; + const oldStderrorWrite = process.stderr.write; + + const cbWrapper = (...args) => { + if (isLoggerData(...args)) { + Reflect.apply(cb, null, args); + } + }; + + // Class methods are read-only in flow, cast to Object to intercept + (process.stdout: Object).write = cbWrapper; + (process.stderr: Object).write = cbWrapper; + + return function () { + (process.stdout: Object).write = oldStdoutWrite; + (process.stderr: Object).write = oldStderrorWrite; + }; +} + +function isLoggerData (line: string) { + try { + const data = JSON.parse(line); + return data.timestamp && + data.message && + data.level; + } catch (ex) { + return false; + } +} diff --git a/src/packages/luxify/test/luxify.test.js b/src/packages/luxify/test/luxify.test.js new file mode 100644 index 00000000..bd0465da --- /dev/null +++ b/src/packages/luxify/test/luxify.test.js @@ -0,0 +1,65 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import luxify from '../index'; + +import setType from '../../../utils/set-type'; + +describe('module "luxify"', () => { + describe('#luxify()', () => { + const [request, response] = setType(() => [{}, {}]); + + it('promisifies a callback based function', () => { + const subject = luxify((req, res, next) => { + next(); + }); + + expect(subject(request, response)).to.be.a('promise'); + }); + + it('resolves when Response#end is called', () => { + const subject = luxify((req, res) => { + res.end('Hello world!'); + }); + + return subject(request, response).then(data => { + expect(data).to.equal('Hello world!'); + }); + }); + + it('resolves when Response#send is called', () => { + const subject = luxify((req, res) => { + Reflect.apply(Reflect.get(res, 'send'), res, ['Hello world!']); + }); + + return subject(request, response).then(data => { + expect(data).to.equal('Hello world!'); + }); + }); + + it('resolves when Response#json is called', () => { + const subject = luxify((req, res) => { + Reflect.apply(Reflect.get(res, 'json'), res, [{ + data: 'Hello world!' + }]); + }); + + return subject(request, response).then(data => { + expect(data).to.deep.equal({ + data: 'Hello world!' + }); + }); + }); + + it('rejects when an error is passed to `next`', () => { + const subject = luxify((req, res, next) => { + next(new Error('Test')); + }); + + return subject(request, response).catch(err => { + expect(err).to.be.a('error'); + }); + }); + }); +}); diff --git a/src/packages/luxify/utils/create-response-proxy.js b/src/packages/luxify/utils/create-response-proxy.js index 9a139d7d..f9bc01e2 100644 --- a/src/packages/luxify/utils/create-response-proxy.js +++ b/src/packages/luxify/utils/create-response-proxy.js @@ -10,7 +10,7 @@ import type { Response } from '../../server'; export default function createResponseProxy( res: Response, resolve: (result: mixed) => void -) { +): Response { return new Proxy(res, { get(target, key) { switch (key) { diff --git a/src/packages/router/route/action/enhancers/resource.js b/src/packages/router/route/action/enhancers/resource.js index ac4f2388..4abc15f4 100644 --- a/src/packages/router/route/action/enhancers/resource.js +++ b/src/packages/router/route/action/enhancers/resource.js @@ -19,9 +19,7 @@ export default function resource(action: Action): Action { if (actionName == 'index') { [data, total] = await Promise.all([ result, - new Promise((resolve, reject) => { - Query.from(result).count().then(resolve, reject); - }) + Query.from(result).count() ]); } else { data = await result; diff --git a/src/packages/router/route/action/test/create-page-links.test.js b/src/packages/router/route/action/test/create-page-links.test.js new file mode 100644 index 00000000..36f508ec --- /dev/null +++ b/src/packages/router/route/action/test/create-page-links.test.js @@ -0,0 +1,220 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import createPageLinks from '../utils/create-page-links'; + +const DOMAIN = 'http://localhost:4000'; +const RESOURCE = 'posts'; + +describe('module "router/route/action"', () => { + describe('util createPageLinks()', () => { + const getOptions = ({ + total = 100, + params = {} + }: { + total?: number; + params?: Object; + } = {}) => ({ + total, + params, + domain: DOMAIN, + pathname: `/${RESOURCE}`, + defaultPerPage: 25 + }); + + it('works with vanilla params', () => { + const base = `${DOMAIN}/${RESOURCE}`; + + [1, 2, 3, 4].forEach(number => { + const opts = getOptions({ + params: { + page: { + number + } + } + }); + + let target = { + self: `${base}?page%5Bnumber%5D=${number}`, + first: base, + last: `${base}?page%5Bnumber%5D=4`, + prev: `${base}?page%5Bnumber%5D=${number - 1}`, + next: `${base}?page%5Bnumber%5D=${number + 1}` + }; + + switch (number) { + case 1: + target = { + ...target, + self: target.first, + prev: null + }; + break; + + case 2: + target = { + ...target, + prev: target.first + }; + break; + + case 4: + target = { + ...target, + next: null + }; + break; + } + + expect(createPageLinks(opts)).to.deep.equal(target); + }); + }); + + it('works with a custom size', () => { + const size = 10; + const base = `${DOMAIN}/${RESOURCE}?page%5Bsize%5D=${size}`; + + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach(number => { + const opts = getOptions({ + params: { + page: { + size, + number + } + } + }); + + let target = { + self: `${base}&page%5Bnumber%5D=${number}`, + first: base, + last: `${base}&page%5Bnumber%5D=10`, + prev: `${base}&page%5Bnumber%5D=${number - 1}`, + next: `${base}&page%5Bnumber%5D=${number + 1}` + }; + + switch (number) { + case 1: + target = { + ...target, + self: target.first, + prev: null + }; + break; + + case 2: + target = { + ...target, + prev: target.first + }; + break; + + case 10: + target = { + ...target, + next: null + }; + break; + } + + expect(createPageLinks(opts)).to.deep.equal(target); + }); + }); + + it('works with complex parameter sets', () => { + const base = + `${DOMAIN}/${RESOURCE}?sort=-created-at&include=user&fields%5Bposts%5D=` + + `title&fields%5Busers%5D=name`; + + [1, 2, 3, 4].forEach(number => { + const opts = getOptions({ + params: { + sort: '-created-at', + include: [ + 'user' + ], + fields: { + posts: [ + 'title' + ], + users: [ + 'name' + ] + }, + page: { + number + } + } + }); + + let target = { + self: `${base}&page%5Bnumber%5D=${number}`, + first: base, + last: `${base}&page%5Bnumber%5D=4`, + prev: `${base}&page%5Bnumber%5D=${number - 1}`, + next: `${base}&page%5Bnumber%5D=${number + 1}` + }; + + switch (number) { + case 1: + target = { + ...target, + self: target.first, + prev: null + }; + break; + + case 2: + target = { + ...target, + prev: target.first + }; + break; + + case 4: + target = { + ...target, + next: null + }; + break; + } + + expect(createPageLinks(opts)).to.deep.equal(target); + }); + }); + + it('works when the total is 0', () => { + const base = `${DOMAIN}/${RESOURCE}`; + const opts = getOptions({ + total: 0 + }); + + expect(createPageLinks(opts)).to.deep.equal({ + self: base, + first: base, + last: base, + prev: null, + next: null + }); + }); + + it('works when the maximum page is exceeded', () => { + const base = `${DOMAIN}/${RESOURCE}`; + const opts = getOptions({ + params: { + page: { + number: 1000 + } + } + }); + + expect(createPageLinks(opts)).to.deep.equal({ + self: null, + first: base, + last: `${base}?page%5Bnumber%5D=4`, + prev: null, + next: null + }); + }); + }); +}); diff --git a/src/packages/router/route/params/test/parameter-group.test.js b/src/packages/router/route/params/test/parameter-group.test.js new file mode 100644 index 00000000..a7312c2d --- /dev/null +++ b/src/packages/router/route/params/test/parameter-group.test.js @@ -0,0 +1,110 @@ +// @flow +import { expect } from 'chai'; +import { it, describe, before } from 'mocha'; + +import Parameter from '../parameter'; +import ParameterGroup from '../parameter-group'; + +describe('module "router/route/params"', () => { + describe('class ParameterGroup', () => { + let subject: ParameterGroup; + + before(() => { + subject = new ParameterGroup([ + ['id', new Parameter({ + type: 'number', + path: 'id', + required: true + })], + ['meta', new ParameterGroup([ + ['date', new Parameter({ + type: 'string', + path: 'meta.date', + required: true + })], + ['vowel', new Parameter({ + type: 'string', + path: 'meta.vowel', + values: [ + 'a', + 'e', + 'i', + 'o', + 'u' + ] + })], + ], { + path: 'meta', + sanitize: true + })] + ], { + path: '', + required: true + }); + }); + + describe('#validate()', () => { + it('fails when required keys are missing', () => { + expect(() => subject.validate({})).to.throw(TypeError); + expect(() => subject.validate({ id: 1, meta: {} })).to.throw(TypeError); + }); + + it('fails when there is a type mismatch', () => { + expect(() => subject.validate({ id: '1' })).to.throw(TypeError); + expect(() => { + subject.validate({ + id: '1', + meta: { + date: Date.now() + } + }); + }).to.throw(TypeError); + }); + + it('fails when there is a value mismatch', () => { + expect(() => { + subject.validate({ + id: 1, + meta: { + date: new Date().toISOString(), + vowel: 'p' + } + }); + }).to.throw(TypeError); + }); + + it('returns the value(s) when the type and value(s) match', () => { + const params = { + id: 1, + meta: { + date: Date(), + vowel: 'a' + } + }; + + expect(subject.validate(params)).to.deep.equal(params); + }); + + it('fails when an unsanitized group contains an invalid key', () => { + expect(() => subject.validate({ test: true })).to.throw(TypeError); + }); + + it('strips out invalid keys when a group is santized ', () => { + const params = { + id: 1, + meta: { + date: Date(), + colors: ['red', 'green', 'blue'], + } + }; + + expect(subject.validate(params)).to.deep.equal({ + id: 1, + meta: { + date: params.meta.date + } + }); + }); + }); + }); +}); diff --git a/src/packages/router/route/params/test/parameter.test.js b/src/packages/router/route/params/test/parameter.test.js new file mode 100644 index 00000000..b6cc1f9b --- /dev/null +++ b/src/packages/router/route/params/test/parameter.test.js @@ -0,0 +1,36 @@ +// @flow +import { expect } from 'chai'; +import { it, describe, before } from 'mocha'; + +import Parameter from '../parameter'; + +describe('module "router/route/params"', () => { + describe('class Parameter', () => { + let subject: Parameter; + + before(() => { + subject = new Parameter({ + type: 'array', + path: 'meta.test', + values: [1, 'test', false] + }); + }); + + describe('#validate()', () => { + it('fails when there is a type mismatch', () => { + expect(() => subject.validate('test')).to.throw(TypeError); + }); + + it('fails when there is a value mismatch', () => { + expect(() => subject.validate([new Date()])).to.throw(TypeError); + }); + + it('returns the value(s) when the type and value(s) match', () => { + expect(subject.validate(['test', false])).to.deep.equal([ + 'test', + false + ]); + }); + }); + }); +}); diff --git a/src/packages/router/route/test/get-dynamic-segments.test.js b/src/packages/router/route/test/get-dynamic-segments.test.js new file mode 100644 index 00000000..c5786d38 --- /dev/null +++ b/src/packages/router/route/test/get-dynamic-segments.test.js @@ -0,0 +1,27 @@ +// @flow + +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import getDynamicSegments from '../utils/get-dynamic-segments'; + +describe('module "router/route"', () => { + describe('util getDynamicSegments()', () => { + it('parses the dynamic segments in a path', () => { + const segments = getDynamicSegments('/posts/:pid/comments/:cid'); + expect(segments).to.deep.equal(['pid', 'cid']); + }); + + it('does not parse static segments in a path', () => { + const segments = getDynamicSegments('/posts'); + expect(segments).to.be.empty; + }); + + it('handles paths containing a trailing forward-slash', () => { + const path = '/posts/:pid/comments/:cid'; + const segments = getDynamicSegments(path); + const segmentsWithTrailingSlash = getDynamicSegments(path + '/'); + expect(segments).to.deep.equal(segmentsWithTrailingSlash); + }); + }); +}); diff --git a/src/packages/router/route/test/get-static-path.test.js b/src/packages/router/route/test/get-static-path.test.js new file mode 100644 index 00000000..a043218e --- /dev/null +++ b/src/packages/router/route/test/get-static-path.test.js @@ -0,0 +1,18 @@ +// @flow + +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import getStaticPath from '../utils/get-static-path'; +import getDynamicSegments from '../utils/get-dynamic-segments'; + +describe('module "router/route"', () => { + describe('util getStaticPath()', () => { + it('replaces the dynamic segments in a path', () => { + const path = '/posts/:pid/comments/:cid'; + const staticPath = '/posts/:dynamic/comments/:dynamic'; + const dynamicSegments = getDynamicSegments(path); + expect(getStaticPath(path, dynamicSegments)).to.equal(staticPath); + }); + }); +}); diff --git a/src/packages/router/route/test/route.test.js b/src/packages/router/route/test/route.test.js new file mode 100644 index 00000000..a68c0483 --- /dev/null +++ b/src/packages/router/route/test/route.test.js @@ -0,0 +1,113 @@ +// @flow + +import { expect } from 'chai'; +import { it, describe, before } from 'mocha'; + +import Route from '../index'; + +import setType from '../../../../utils/set-type'; +import { getTestApp } from '../../../../../test/utils/get-test-app'; + +import type Controller from '../../../controller'; + +describe('module "router/route"', () => { + describe('class Route', () => { + let staticRoute: Route; + let dynamicRoute: Route; + let dataRoute: Route; + + before(async () => { + const { controllers } = await getTestApp(); + const controller: Controller = setType(() => controllers.get('posts')); + + staticRoute = new Route({ + controller, + type: 'collection', + path: 'posts', + action: 'index', + method: 'GET', + }); + dynamicRoute = new Route({ + controller, + type: 'member', + path: 'posts/:id', + action: 'show', + method: 'GET', + }); + dataRoute = new Route({ + controller, + type: 'member', + path: 'posts/:id', + action: 'create', + method: 'PATCH', + }); + }); + + describe('#parseParams()', () => { + it('is empty for static paths', () => { + expect(staticRoute.parseParams('/posts/1')).to.be.empty; + }); + + it('contains params matching dynamic segments', () => { + expect(dynamicRoute.parseParams('/posts/1')).to.deep.equal({ id: 1 }); + }); + + it('does not contain params for unmatched dynamic segments', () => { + expect(dynamicRoute.parseParams('/posts/1/2')).to.deep.equal({ id: 1 }); + }); + }); + + describe('#getDefaultParams()', () => { + describe('with collection route', () => { + let params: Object; + + before(() => { + params = staticRoute.getDefaultParams(); + }); + + it('contains sort', () => { + expect(params).to.include.keys('sort'); + }); + + it('contains page cursor', () => { + expect(params).to.include.keys('page'); + expect(params.page).to.include.keys('size', 'number'); + }); + + it('contains model fields', () => { + const { controller: { attributes, model } } = staticRoute; + expect(params.fields).to.include.keys(model.resourceName); + expect(params.fields[model.resourceName]).to.deep.equal(attributes); + }); + }); + + describe('with member route', () => { + let params: Object; + + before(() => { + params = dynamicRoute.getDefaultParams(); + }); + + it('contains model fields', () => { + const { controller: { attributes, model } } = staticRoute; + expect(params.fields).to.include.keys(model.resourceName); + expect(params.fields[model.resourceName]).to.deep.equal(attributes); + }); + }); + + describe('with data route', () => { + let params: Object; + + before(() => { + params = dynamicRoute.getDefaultParams(); + }); + + it('contains model fields', () => { + const { controller: { attributes, model } } = dataRoute; + expect(params.fields).to.include.keys(model.resourceName); + expect(params.fields[model.resourceName]).to.deep.equal(attributes); + }); + }); + }); + }); +}); diff --git a/src/packages/router/test/fixtures/data.js b/src/packages/router/test/fixtures/data.js new file mode 100644 index 00000000..24fb86e2 --- /dev/null +++ b/src/packages/router/test/fixtures/data.js @@ -0,0 +1,14 @@ +// @flow +export const ROUTE_KEY = 'GET:/users'; + +export const RESOURCE_KEYS = [ + 'GET:/posts', + 'GET:/posts/:dynamic', + 'POST:/posts', + 'PATCH:/posts/:dynamic', + 'DELETE:/posts/:dynamic', + 'HEAD:/posts', + 'HEAD:/posts/:dynamic', + 'OPTIONS:/posts', + 'OPTIONS:/posts/:dynamic' +]; diff --git a/src/packages/router/test/router.test.js b/src/packages/router/test/router.test.js new file mode 100644 index 00000000..b766afae --- /dev/null +++ b/src/packages/router/test/router.test.js @@ -0,0 +1,71 @@ +// @flow +import { expect } from 'chai'; +import { it, before, describe } from 'mocha'; + +import { ROUTE_KEY, RESOURCE_KEYS } from './fixtures/data'; + +import Route from '../route'; +import Router from '../index'; + +import setType from '../../../utils/set-type'; +import { getTestApp } from '../../../../test/utils/get-test-app'; + +import type { Request } from '../../server'; + +describe('module "router"', () => { + describe('class Router', () => { + let subject: Router; + + before(async () => { + const { controllers } = await getTestApp(); + + subject = new Router({ + controllers, + controller: setType(() => controllers.get('application')), + + routes() { + this.resource('posts'); + this.resource('users', { + only: ['index'] + }); + } + }); + }); + + it('can define a single route', () => { + expect(subject.has(ROUTE_KEY)).to.be.true; + }); + + it('can define a complete resource', () => { + RESOURCE_KEYS.forEach(key => { + expect(subject.has(key)).to.be.true; + }); + }); + + describe('#match()', () => { + it('can match a route for a request with a dynamic url', () => { + const req: Request = setType(() => ({ + method: 'GET', + + url: { + pathname: '/posts/1' + } + })); + + expect(subject.match(req)).to.be.an.instanceof(Route); + }); + + it('can match a route for a request with a non-dynamic url', () => { + const req: Request = setType(() => ({ + method: 'GET', + + url: { + pathname: '/posts' + } + })); + + expect(subject.match(req)).to.be.an.instanceof(Route); + }); + }); + }); +}); diff --git a/src/packages/serializer/test/fixtures/data.js b/src/packages/serializer/test/fixtures/data.js new file mode 100644 index 00000000..eb981720 --- /dev/null +++ b/src/packages/serializer/test/fixtures/data.js @@ -0,0 +1,76 @@ +// @flow +export const FIXTURES = [ + { + id: 1, + body: 'Test...', + title: 'New Post 1', + isPublic: true, + createdAt: '2016-08-10T22:18:43.593Z', + updatedAt: '2016-08-10T22:18:43.593Z', + user: { + id: 1, + name: 'New User 1', + email: 'new-user-1@postlight.com', + password: 'password', + createdAt: '2016-08-10T22:18:43.593Z', + updatedAt: '2016-08-10T22:18:43.593Z' + } + }, + { + id: 2, + body: 'Test...', + title: 'New Post 2', + isPublic: true, + createdAt: '2016-08-10T22:18:43.593Z', + updatedAt: '2016-08-10T22:18:43.593Z', + user: { + id: 1, + name: 'New User 1', + email: 'new-user-1@postlight.com', + password: 'password', + createdAt: '2016-08-10T22:18:43.593Z', + updatedAt: '2016-08-10T22:18:43.593Z' + } + }, + { + id: 3, + body: 'Test...', + title: 'New Post 3', + user: null, + isPublic: true, + createdAt: '2016-08-10T22:18:43.593Z', + updatedAt: '2016-08-10T22:18:43.593Z' + }, + { + id: 4, + body: 'Test...', + title: 'New Post 4', + isPublic: true, + createdAt: '2016-08-10T22:18:43.593Z', + updatedAt: '2016-08-10T22:18:43.593Z', + user: { + id: 2, + name: 'New User 2', + email: 'new-user-2@postlight.com', + password: 'password', + createdAt: '2016-08-10T22:18:43.593Z', + updatedAt: '2016-08-10T22:18:43.593Z' + } + }, + { + id: 5, + body: 'Test...', + title: 'New Post 5', + isPublic: true, + createdAt: '2016-08-10T22:18:43.593Z', + updatedAt: '2016-08-10T22:18:43.593Z', + user: { + id: 2, + name: 'New User 2', + email: 'new-user-2@postlight.com', + password: 'password', + createdAt: '2016-08-10T22:18:43.593Z', + updatedAt: '2016-08-10T22:18:43.593Z' + } + } +]; diff --git a/src/packages/serializer/test/serializer.test.js b/src/packages/serializer/test/serializer.test.js new file mode 100644 index 00000000..7f79161a --- /dev/null +++ b/src/packages/serializer/test/serializer.test.js @@ -0,0 +1,291 @@ +// @flow +import { expect } from 'chai'; +import { it, before, describe } from 'mocha'; +import { dasherize, underscore } from 'inflection'; + +import { FIXTURES } from './fixtures/data'; + +import entries from '../../../utils/entries'; +import setType from '../../../utils/set-type'; +import { getTestApp } from '../../../../test/utils/get-test-app'; + +import type Serializer from '../index'; +import type Application from '../../application'; + +import type { + JSONAPI$DocumentLinks, + JSONAPI$ResourceObject, + JSONAPI$IdentifierObject +} from '../../jsonapi'; + +const DOMAIN = 'http://localhost:4000'; +const JSONAPI_VERSION = '1.0'; + +const assertLinks = (subject: JSONAPI$DocumentLinks = {}) => { + expect(subject).to.have.all.keys(['self']); + expect(subject.self).to.be.a('string'); + + if (typeof subject.self === 'string') { + expect(subject.self.startsWith(DOMAIN)).to.be.true; + } +}; + +const assertIdentifier = ( + subject: JSONAPI$IdentifierObject, + { id, type }: { + id?: string; + type?: string; + } = {} +) => { + if (id) { + expect(subject.id).to.equal(id); + } else { + expect(subject.id).to.be.a('string'); + } + + if (type) { + expect(subject.type).to.equal(type); + } else { + expect(subject.type).to.be.a('string'); + } +}; + +const createAssertion = ({ attributes, hasOne, hasMany }: Serializer<*>) => ( + subject: JSONAPI$ResourceObject, + id?: string, + type?: string +) => { + hasOne = hasOne.map(str => dasherize(underscore(str))); + hasMany = hasMany.map(str => dasherize(underscore(str))); + attributes = attributes.map(str => dasherize(underscore(str))); + + assertIdentifier(subject, { + id, + type + }); + + if (subject.attributes) { + expect(subject.attributes).to.have.all.keys(attributes); + } + + if (subject.relationships) { + const { relationships } = subject; + + expect(relationships).to.have.all.keys([...hasOne, ...hasMany]); + entries(relationships).forEach(([, relationship]) => { + if (!relationship) { + expect(relationship).to.be.null; + } else { + expect(relationship).to.have.any.keys([ + 'id', + 'type', + 'data', + 'links' + ]); + + expect(relationship).to.have.property('data'); + + if (Array.isArray(relationship.data)) { + relationship.data.forEach(item => assertIdentifier(item)); + } else { + assertIdentifier(relationship.data); + } + + if (relationship.links) { + assertLinks(relationship.links); + } + } + }); + } + + if (subject.links) { + assertLinks(subject.links); + } +}; + +describe('module "serializer"', () => { + describe('class Serializer', () => { + let data; + let subject: Serializer<*>; + let assertPost: Function; + let assertUser: Function; + + before(async () => { + const app: Application = await getTestApp(); + const Post = app.models.get('post'); + const User = app.models.get('user'); + const PostsSerializer = app.serializers.get('posts'); + const UsersSerializer = app.serializers.get('users'); + + if (!Post || !User || !PostsSerializer || !UsersSerializer) { + throw new Error('TestApp is invalid'); + } + + subject = PostsSerializer; + assertPost = createAssertion(PostsSerializer); + assertUser = createAssertion(UsersSerializer); + + data = FIXTURES.map(({ user, ...attrs }) => new Post({ + ...attrs, + user: user ? new User(user) : null + })); + }); + + describe('#format()', () => { + it('converts a single of model to a JSONAPI document', async () => { + const [record] = data; + const result = await subject.format({ + data: record, + domain: DOMAIN, + include: [], + links: { + self: `${DOMAIN}/posts/${record.id}` + } + }); + + expect(result).to.have.all.keys([ + 'data', + 'links', + 'jsonapi' + ]); + + expect(result.data).to.be.an('object'); + expect(result.jsonapi).to.deep.equal({ version: JSONAPI_VERSION }); + + assertLinks(result.links); + + if (result.data && !Array.isArray(result.data)) { + assertPost(result.data, String(record.id), record.resourceName); + } + }); + + it('converts an `Array` of models to a JSONAPI document', async () => { + const result = await subject.format({ + data: setType(() => data), + domain: DOMAIN, + include: [], + links: { + self: `${DOMAIN}/posts` + } + }); + + expect(result).to.have.all.keys([ + 'data', + 'links', + 'jsonapi' + ]); + + expect(result.data).to.be.an('array').with.length.above(0); + expect(result.jsonapi).to.deep.equal({ version: JSONAPI_VERSION }); + + assertLinks(result.links); + + if (Array.isArray(result.data)) { + result.data.forEach(item => { + assertPost(item); + }); + } + }); + + it('can include relationships for a single model', async () => { + const [record] = data; + const result = await subject.format({ + data: record, + domain: DOMAIN, + include: ['user'], + links: { + self: `${DOMAIN}/posts/${record.id}` + } + }); + + expect(result).to.have.all.keys([ + 'data', + 'links', + 'jsonapi', + 'included' + ]); + + expect(result.data).to.be.an('object'); + expect(result.jsonapi).to.deep.equal({ version: JSONAPI_VERSION }); + expect(result.included).to.be.an('array').with.length.above(0); + + assertLinks(result.links); + + if (result.data && !Array.isArray(result.data)) { + assertPost(result.data, String(record.id), record.resourceName); + } + + if (Array.isArray(result.included)) { + result.included.forEach(item => { + assertUser(item); + }); + } + }); + + it('can include relationships for an `Array` of models', async () => { + const result = await subject.format({ + data: setType(() => data), + domain: DOMAIN, + include: ['user'], + links: { + self: `${DOMAIN}/posts` + } + }); + + expect(result).to.have.all.keys([ + 'data', + 'links', + 'jsonapi', + 'included' + ]); + + expect(result.data).to.be.an('array').with.length.above(0); + expect(result.jsonapi).to.deep.equal({ version: JSONAPI_VERSION }); + expect(result.included).to.be.an('array').with.length.above(0); + + assertLinks(result.links); + + if (Array.isArray(result.data)) { + result.data.forEach(item => { + assertPost(item); + }); + } + + if (Array.isArray(result.included)) { + result.included.forEach(item => { + assertUser(item); + }); + } + }); + }); + + describe('#formatOne()', () => { + it('converts a single model to a JSONAPI resource object', async () => { + const [record] = data; + const result = await subject.formatOne({ + item: record, + links: false, + domain: DOMAIN, + include: [], + included: [] + }); + + assertPost(result, String(record.id), record.resourceName); + }); + }); + + describe('#formatRelationship()', () => { + it('can build a JSONAPI relationship object', async () => { + const record = await Reflect.get(data[0], 'user'); + const result = await subject.formatRelationship({ + item: record, + domain: DOMAIN, + include: false, + included: [] + }); + + assertUser(result.data, String(record.id), record.resourceName); + }); + }); + }); +}); diff --git a/src/packages/server/request/parser/errors/malformed-request-error.js b/src/packages/server/request/parser/errors/malformed-request-error.js index 82f7d7e1..ee2ba14c 100644 --- a/src/packages/server/request/parser/errors/malformed-request-error.js +++ b/src/packages/server/request/parser/errors/malformed-request-error.js @@ -5,7 +5,7 @@ import { line } from '../../../../logger'; /** * @private */ -class MalformedRequestError extends TypeError { +class MalformedRequestError extends SyntaxError { constructor() { super(line` There was an error parsing the request body. Please make sure that the diff --git a/src/packages/server/request/test/request.test.js b/src/packages/server/request/test/request.test.js new file mode 100644 index 00000000..d813aedf --- /dev/null +++ b/src/packages/server/request/test/request.test.js @@ -0,0 +1,294 @@ +// @flow +import fetch from 'node-fetch'; +import { expect } from 'chai'; +import { createServer } from 'http'; +import { parse as parseURL } from 'url'; +import { it, describe, before } from 'mocha'; + +import { MIME_TYPE } from '../../../jsonapi'; +import { getDomain, createRequest, parseRequest } from '../index'; + +import { getTestApp } from '../../../../../test/utils/get-test-app'; + +const DOMAIN = 'http://localhost:4100'; + +describe('module "server/request"', () => { + let test; + + before(async () => { + const { logger, router } = await getTestApp(); + + test = (path, opts, fn) => { + const server = createServer((req, res) => { + req = createRequest(req, { + logger, + router + }); + + const close = () => { + res.statusCode = 200; + res.end(); + }; + + fn(req).then(close, close); + }); + + const cleanup = () => { + server.close(); + }; + + server.listen(4100); + + return fetch(DOMAIN + path, opts).then(cleanup, cleanup); + }; + }); + + describe('#getDomain()', () => { + it('returns the domain (`${PROTOCOL}://${HOST}`) of a request', () => { + return test('/post', { + headers: { + host: 'localhost' + } + }, async req => { + const result = getDomain(req); + + expect(result).to.equal(DOMAIN); + }); + }); + }); + + describe('#createRequest()', () => { + it('can create a Request from an http.IncomingMessage', () => { + return test('/posts', { + headers: { + 'x-test': 'true' + } + }, async ({ url, route, method, logger, headers }) => { + const parsed = parseURL(`${DOMAIN}/posts`); + + expect(url).to.deep.equal(parsed); + expect(route).to.be.ok; + expect(method).to.equal('GET'); + expect(logger).to.equal(logger); + expect(headers).to.be.an.instanceof(Map); + expect(headers.get('x-test')).to.equal('true'); + }); + }); + + it('accepts a HTTP-Method-Override header', () => { + return test('/posts', { + method: 'POST', + headers: { + 'HTTP-Method-Override': 'PATCH' + } + }, async ({ method }) => { + expect(method).to.equal('PATCH'); + }); + }); + }); + + describe('#parseRequest()', () => { + it('can parse params from a GET request', () => { + const url = '/posts?' + + 'fields[posts]=body,title' + + '&fields[users]=name' + + '&include=user'; + + return test(url, { + method: 'GET' + }, async req => { + const params = await parseRequest(req); + + expect(params).to.deep.equal({ + fields: ['body', 'title'], + include: ['user'] + }); + }); + }); + + it('can parse params from a POST request', () => { + return test('/posts?include=user', { + method: 'POST', + body: { + data: { + type: 'posts', + attributes: { + title: 'New Post 1', + 'is-public': true + }, + relationships: { + user: { + data: { + type: 'users', + id: 1 + } + }, + tags: { + data: [ + { + type: 'tags', + id: 1 + }, + { + type: 'tags', + id: 2 + }, + { + type: 'tags', + id: 3 + } + ] + } + } + } + }, + headers: { + 'Content-Type': MIME_TYPE + } + }, async req => { + const params = await parseRequest(req); + + expect(params).to.deep.equal({ + data: { + type: 'posts', + attributes: { + title: 'New Post 1', + isPublic: true + }, + relationships: { + user: { + data: { + type: 'users', + id: 1 + } + }, + tags: { + data: [ + { + type: 'tags', + id: 1 + }, + { + type: 'tags', + id: 2 + }, + { + type: 'tags', + id: 3 + } + ] + } + } + }, + include: ['user'] + }); + }); + }); + + it('can parse params from a PATCH request', () => { + return test('/posts/1?include=user', { + method: 'PATCH', + data: { + id: 1, + type: 'posts', + attributes: { + title: 'New Post 1', + 'is-public': true + }, + relationships: { + user: { + data: { + type: 'users', + id: 1 + } + }, + tags: { + data: [ + { + type: 'tags', + id: 1 + }, + { + type: 'tags', + id: 2 + }, + { + type: 'tags', + id: 3 + } + ] + } + } + }, + headers: { + 'Content-Type': MIME_TYPE + } + }, async req => { + const params = await parseRequest(req); + + expect(params).to.deep.equal({ + data: { + type: 'posts', + attributes: { + title: 'New Post 1', + isPublic: true + }, + relationships: { + user: { + data: { + type: 'users', + id: 1 + } + }, + tags: { + data: [ + { + type: 'tags', + id: 1 + }, + { + type: 'tags', + id: 2 + }, + { + type: 'tags', + id: 3 + } + ] + } + } + }, + include: ['user'] + }); + }); + }); + + it('rejects when a POST request body is invalid', () => { + return test('/posts', { + method: 'POST', + body: '{[{not json,,,,,}]}', + headers: { + 'Content-Type': MIME_TYPE + } + }, req => { + return parseRequest(req).catch(err => { + expect(err).to.be.an.instanceof(SyntaxError); + }); + }); + }); + + it('rejects when a PATCH request body is invalid', () => { + return test('/posts', { + method: 'PATCH', + body: '{[{not json,,,,,}]}', + headers: { + 'Content-Type': MIME_TYPE + } + }, req => { + return parseRequest(req).catch(err => { + expect(err).to.be.an.instanceof(SyntaxError); + }); + }); + }); + }); +}); diff --git a/src/packages/server/responder/test/responder.test.js b/src/packages/server/responder/test/responder.test.js new file mode 100644 index 00000000..006b91db --- /dev/null +++ b/src/packages/server/responder/test/responder.test.js @@ -0,0 +1,277 @@ +// @flow +import fetch from 'node-fetch'; +import { expect } from 'chai'; +import { createServer } from 'http'; +import { it, before, describe } from 'mocha'; + +import { MIME_TYPE, VERSION } from '../../../jsonapi'; +import { createRequest } from '../../request'; +import { createResponse } from '../../response'; +import { createResponder } from '../index'; + +import { getTestApp } from '../../../../../test/utils/get-test-app'; + +const DOMAIN = 'http://localhost:4100'; + +describe('module "server/responder"', () => { + let test; + + before(async () => { + const { logger, router } = await getTestApp(); + + test = fn => new Promise((resolve, reject) => { + const server = createServer((req, res) => { + req = createRequest(req, { + logger, + router + }); + + res = createResponse(res, { + logger + }); + + fn(req, res); + }).listen(4100); + + const cleanup = () => { + server.close(); + }; + + return fetch(DOMAIN) + .then(res => { + resolve(res); + cleanup(); + }) + .catch(err => { + reject(err); + cleanup(); + }); + }); + }); + + describe('#createResponder()', () => { + it('creates a #respond() function', () => { + return test((req, res) => { + const result = createResponder(req, res); + + expect(result).to.be.a('function'); + expect(result.length).to.equal(1); + expect(result).to.not.throw(Error); + }); + }); + + describe('#respond()', () => { + describe('- responding with a string', () => { + it('works as expected', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + res.setHeader('Content-Type', 'text/plain'); + + respond('Hello World'); + }); + + expect(result.status).to.equal(200); + expect(result.headers.get('Content-Type')).to.equal('text/plain'); + expect(await result.text()).to.equal('Hello World'); + }); + }); + + describe('- responding with a number', () => { + it('works with `204`', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + respond(204); + }); + + expect(result.status).to.equal(204); + expect(result.headers.get('Content-Type')).to.be.null; + expect(await result.text()).to.equal(''); + }); + + it('works with `400`', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + respond(400); + }); + + expect(result.status).to.equal(400); + expect(result.headers.get('Content-Type')).to.equal(MIME_TYPE); + expect(await result.json()).to.deep.equal({ + errors: [ + { + status: '400', + title: 'Bad Request' + } + ], + jsonapi: { + version: VERSION + } + }); + }); + }); + + describe('- responding with a boolean', () => { + it('works with `true`', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + respond(true); + }); + + expect(result.status).to.equal(204); + expect(result.headers.get('Content-Type')).to.be.null; + expect(await result.text()).to.equal(''); + }); + + it('works with `false`', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + respond(false); + }); + + expect(result.status).to.equal(401); + expect(result.headers.get('Content-Type')).to.equal(MIME_TYPE); + expect(await result.json()).to.deep.equal({ + errors: [ + { + status: '401', + title: 'Unauthorized' + } + ], + jsonapi: { + version: VERSION + } + }); + }); + }); + + describe('- responding with an object', () => { + it('works with `null`', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + respond(null); + }); + + expect(result.status).to.equal(404); + expect(result.headers.get('Content-Type')).to.equal(MIME_TYPE); + expect(await result.json()).to.deep.equal({ + errors: [ + { + status: '404', + title: 'Not Found' + } + ], + jsonapi: { + version: VERSION + } + }); + }); + + it('works with an object', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + respond({ test: true }); + }); + + expect(result.status).to.equal(200); + expect(result.headers.get('Content-Type')).to.equal(MIME_TYPE); + expect(await result.json()).to.deep.equal({ test: true }); + }); + + it('works with an array', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + respond(['test', true]); + }); + + expect(result.status).to.equal(200); + expect(result.headers.get('Content-Type')).to.equal(MIME_TYPE); + expect(await result.json()).to.deep.equal(['test', true]); + }); + }); + + describe('- responding with an error', () => { + it('works with vanilla errors', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + respond(new Error('test')); + }); + + expect(result.status).to.equal(500); + expect(result.headers.get('Content-Type')).to.equal(MIME_TYPE); + expect(await result.json()).to.deep.equal({ + errors: [ + { + status: '500', + title: 'Internal Server Error', + detail: 'test' + } + ], + jsonapi: { + version: VERSION + } + }); + }); + + it('works with errors containing a `statusCode` property', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + class ForbiddenError extends Error { + statusCode = 403; + } + + respond(new ForbiddenError('test')); + }); + + expect(result.status).to.equal(403); + expect(result.headers.get('Content-Type')).to.equal(MIME_TYPE); + expect(await result.json()).to.deep.equal({ + errors: [ + { + status: '403', + title: 'Forbidden', + detail: 'test' + } + ], + jsonapi: { + version: VERSION + } + }); + }); + }); + + describe('- responding with undefined', () => { + it('works as expected', async () => { + const result = await test((req, res) => { + const respond = createResponder(req, res); + + respond(); + }); + + expect(result.status).to.equal(404); + expect(result.headers.get('Content-Type')).to.equal(MIME_TYPE); + expect(await result.json()).to.deep.equal({ + errors: [ + { + status: '404', + title: 'Not Found' + } + ], + jsonapi: { + version: VERSION + } + }); + }); + }); + }); + }); +}); diff --git a/src/packages/server/response/test/response.test.js b/src/packages/server/response/test/response.test.js new file mode 100644 index 00000000..7ebfa87a --- /dev/null +++ b/src/packages/server/response/test/response.test.js @@ -0,0 +1,50 @@ +// @flow +import fetch from 'node-fetch'; +import { expect } from 'chai'; +import { createServer } from 'http'; +import { it, describe, before } from 'mocha'; + +import { createResponse } from '../index'; + +import { getTestApp } from '../../../../../test/utils/get-test-app'; + +const DOMAIN = 'http://localhost:4100'; + +describe('module "server/response"', () => { + let test; + + before(async () => { + const { logger } = await getTestApp(); + + test = (path, fn) => { + const server = createServer((req, res) => { + res = createResponse(res, { + logger + }); + + const close = () => { + res.statusCode = 200; + res.end(); + }; + + fn(res).then(close, close); + }); + + const cleanup = () => { + server.close(); + }; + + server.listen(4100); + + return fetch(DOMAIN + path).then(cleanup, cleanup); + }; + }); + + describe('#createResponse()', () => { + it('can create a Response from an http.ServerResponse', () => { + return test('/posts', async ({ stats }) => { + expect(stats).to.deep.equal([]); + }); + }); + }); +}); diff --git a/src/packages/server/test/server.test.js b/src/packages/server/test/server.test.js new file mode 100644 index 00000000..9da5d96b --- /dev/null +++ b/src/packages/server/test/server.test.js @@ -0,0 +1,43 @@ +// @flow +import fetch from 'node-fetch'; +import { expect } from 'chai'; +import { it, before, after, describe } from 'mocha'; + +import Server from '../index'; + +import { getTestApp } from '../../../../test/utils/get-test-app'; + +const PORT = 4100; +const DOMAIN = `http://localhost:${PORT}`; + +describe('module "server"', () => { + describe('class Server', () => { + let subject; + + before(async () => { + const { logger, router } = await getTestApp(); + + subject = new Server({ + logger, + router, + cors: { + enabled: false + } + }); + + subject.listen(PORT); + }); + + after(() => { + subject.instance.close(); + }); + + describe('#listen()', () => { + it('enables incoming connections to reach the application', () => { + return fetch(`${DOMAIN}/health`).then(({ status }) => { + expect(status).to.equal(204); + }); + }); + }); + }); +}); diff --git a/src/utils/present.js b/src/utils/present.js index 1dc7675b..bb5386f4 100644 --- a/src/utils/present.js +++ b/src/utils/present.js @@ -1,6 +1,6 @@ // @flow import isNull from './is-null'; -import isUndefined from './is-null'; +import isUndefined from './is-undefined'; /** * @private diff --git a/src/utils/range.js b/src/utils/range.js index 4dfd70cf..36f2e35f 100644 --- a/src/utils/range.js +++ b/src/utils/range.js @@ -3,7 +3,10 @@ /** * @private */ -export default function* range(start: number, end: number): Iterable { +export default function* range( + start: number, + end: number +): Generator { while (start <= end) { yield start++; } diff --git a/src/utils/stringify.js b/src/utils/stringify.js index e8abd923..c3c41989 100644 --- a/src/utils/stringify.js +++ b/src/utils/stringify.js @@ -1,18 +1,13 @@ // @flow -import isNull from './is-null'; import isObject from './is-object'; /** * @private */ -export default function stringify(value?: ?mixed, spaces?: number): string { +export default function stringify(value?: ?mixed, spaces?: number) { if (isObject(value) || Array.isArray(value)) { return JSON.stringify(value, null, spaces); - } else if (isNull(value)) { - return 'null'; - } else if (value && typeof value.toString === 'function') { - return value.toString(); } else { - return 'undefined'; + return String(value); } } diff --git a/src/utils/test/compact.test.js b/src/utils/test/compact.test.js new file mode 100644 index 00000000..37f0d2d6 --- /dev/null +++ b/src/utils/test/compact.test.js @@ -0,0 +1,32 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import compact from '../compact'; + +describe('util compact()', () => { + it('removes null and undefined values from an `Array`', () => { + const result = compact([0, 'a', 1, null, {}, undefined, false]); + + expect(result).to.have.lengthOf(5); + expect(result).to.not.include.members([null, undefined]); + }); + + it('removes null and undefined values from an `Object`', () => { + const values = { + a: 0, + b: 'a', + c: 1, + d: {}, + e: false + }; + + const result = compact({ + ...values, + f: null, + g: undefined + }); + + expect(result).to.deep.equal(values); + }); +}); diff --git a/src/utils/test/create-query-string.test.js b/src/utils/test/create-query-string.test.js new file mode 100644 index 00000000..bd2f95d8 --- /dev/null +++ b/src/utils/test/create-query-string.test.js @@ -0,0 +1,15 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import createQueryString from '../create-query-string'; + +describe('util createQueryString()', () => { + const subject = { a: 1, b: { a: 1 } }; + + it('can build a query string from a nested object', () => { + const result = createQueryString(subject); + + expect(result).to.equal('a=1&b%5Ba%5D=1'); + }); +}); diff --git a/src/utils/test/entries.test.js b/src/utils/test/entries.test.js new file mode 100644 index 00000000..998a24ce --- /dev/null +++ b/src/utils/test/entries.test.js @@ -0,0 +1,19 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import entries from '../entries'; + +describe('util entries()', () => { + it('creates an `Array` of key-value pairs from an object', () => { + expect(entries({ + a: 1, + b: 2, + c: 3 + })).to.deep.equal([ + ['a', 1], + ['b', 2], + ['c', 3], + ]); + }); +}); diff --git a/src/utils/test/exec.test.js b/src/utils/test/exec.test.js new file mode 100644 index 00000000..fd913afa --- /dev/null +++ b/src/utils/test/exec.test.js @@ -0,0 +1,20 @@ +// @flow +import { EOL } from 'os'; +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import exec from '../exec'; + +describe('util exec()', () => { + it('works as a `Promise` based interface to child_proces.exec', () => { + exec('echo Test', { encoding: 'utf8' }).then(data => { + expect(data).to.equal(`Test${EOL}`); + }); + }); + + it('can properly catch errors', () => { + exec('this-is-definitely-not-a-command').catch(err => { + expect(err).to.be.an('error'); + }); + }); +}); diff --git a/src/utils/test/insert.test.js b/src/utils/test/insert.test.js new file mode 100644 index 00000000..c3fc461b --- /dev/null +++ b/src/utils/test/insert.test.js @@ -0,0 +1,21 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import insert from '../insert'; + +describe('util insert()', () => { + it('inserts elements into an `Array` in place', () => { + const subject = new Array(3); + + insert(subject, [1, 2, 3]); + + expect(subject).to.deep.equal([1, 2, 3]); + }); + + it('returns the destination `Array`', () => { + const subject = new Array(3); + + expect(insert(subject, [1, 2, 3])).to.equal(subject); + }); +}); diff --git a/src/utils/test/is-buffer.test.js b/src/utils/test/is-buffer.test.js new file mode 100644 index 00000000..9419314e --- /dev/null +++ b/src/utils/test/is-buffer.test.js @@ -0,0 +1,11 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import isBuffer from '../is-buffer.js'; + +describe('util isBuffer()', () => { + it('returns true when a `Buffer` is passed in as an argument', () => { + expect(isBuffer(new Buffer('', 'utf8'))).to.be.true; + }); +}); diff --git a/src/utils/test/is-null.test.js b/src/utils/test/is-null.test.js new file mode 100644 index 00000000..59319d68 --- /dev/null +++ b/src/utils/test/is-null.test.js @@ -0,0 +1,22 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import isNull from '../is-null'; + +describe('util isNull()', () => { + it('returns false when an `Object` is passed in as an argument', () => { + expect(isNull({})).to.be.false; + }); + + it('returns false when falsy values are passed in as an argument', () => { + expect(isNull(0)).to.be.false; + expect(isNull('')).to.be.false; + expect(isNull(NaN)).to.be.false; + expect(isNull(undefined)).to.be.false; + }); + + it('returns true when `null` is passed in as an argument', () => { + expect(isNull(null)).to.be.true; + }); +}); diff --git a/src/utils/test/is-object.test.js b/src/utils/test/is-object.test.js new file mode 100644 index 00000000..020f5f6e --- /dev/null +++ b/src/utils/test/is-object.test.js @@ -0,0 +1,23 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import isObject from '../is-object'; + +describe('util isObject()', () => { + it ('returns false when an `null` is passed in as an argument', () => { + expect(isObject(null)).to.be.false; + }); + + it ('returns false when an `Array` is passed in as an argument', () => { + expect(isObject([])).to.be.false; + }); + + it('returns true when an `Object` is passed in as an argument', () => { + class SomeObject {} + + expect(isObject({})).to.be.true; + expect(isObject(new SomeObject())).to.be.true; + expect(isObject(Object.create(null))).to.be.true; + }); +}); diff --git a/src/utils/test/is-undefined.test.js b/src/utils/test/is-undefined.test.js new file mode 100644 index 00000000..0f9affb2 --- /dev/null +++ b/src/utils/test/is-undefined.test.js @@ -0,0 +1,20 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import isUndefined from '../is-undefined'; + +describe('util isUndefined()', () => { + it('returns false when falsy values are passed in as an argument', () => { + expect(isUndefined(0)).to.be.false; + expect(isUndefined('')).to.be.false; + expect(isUndefined(NaN)).to.be.false; + expect(isUndefined(null)).to.be.false; + }); + + it('returns true when `undefined` is passed in as an argument', () => { + expect(isUndefined()).to.be.true; + expect(isUndefined(void 0)).to.be.true; + expect(isUndefined(undefined)).to.be.true; + }); +}); diff --git a/src/utils/test/k.test.js b/src/utils/test/k.test.js new file mode 100644 index 00000000..a4e18955 --- /dev/null +++ b/src/utils/test/k.test.js @@ -0,0 +1,15 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import K from '../k'; + +describe('util K()', () => { + it('always returns the context it is called with', () => { + const obj = {}; + + expect(Reflect.apply(K, 1, [])).to.equal(1); + expect(Reflect.apply(K, obj, [])).to.equal(obj); + expect(Reflect.apply(K, 'Test', [])).to.equal('Test'); + }); +}); diff --git a/src/utils/test/merge.test.js b/src/utils/test/merge.test.js new file mode 100644 index 00000000..e45f8629 --- /dev/null +++ b/src/utils/test/merge.test.js @@ -0,0 +1,87 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import merge from '../merge'; + +describe('util merge()', () => { + it('recursively merges two objects together', () => { + const x = { + a: 1, + b: 2, + c: { + i: 'a', + ii: 'b', + iii: 'c', + iv: [1, 2, 3] + } + }; + + const y = { + a: 1, + b: '2', + c: { + i: 1, + ii: 'b', + iii: 3 + } + }; + + expect(merge(x, y)).to.deep.equal({ + a: 1, + b: '2', + c: { + i: 1, + ii: 'b', + iii: 3, + iv: [1, 2, 3] + } + }); + }); + + it('does not mutate the source objects', () => { + const x = { + a: 1, + b: 2, + c: { + i: 'a', + ii: 'b', + iii: 'c', + iv: [1, 2, 3] + } + }; + + const y = { + a: 1, + b: '2', + c: { + i: 1, + ii: 'b', + iii: 3 + } + }; + + merge(x, y); + + expect(x).to.deep.equal({ + a: 1, + b: 2, + c: { + i: 'a', + ii: 'b', + iii: 'c', + iv: [1, 2, 3] + } + }); + + expect(y).to.deep.equal({ + a: 1, + b: '2', + c: { + i: 1, + ii: 'b', + iii: 3 + } + }); + }); +}); diff --git a/src/utils/test/omit.test.js b/src/utils/test/omit.test.js new file mode 100644 index 00000000..d2890b84 --- /dev/null +++ b/src/utils/test/omit.test.js @@ -0,0 +1,18 @@ +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import omit from '../omit'; + +describe('util omit()', () => { + it('filters out keys that are passed as arguments', () => { + expect(omit({ a: 1, b: 2, c: 3 }, 'b', 'c')).to.deep.equal({ a: 1 }); + }); + + it('does not mutate the source object', () => { + const subject = { a: 1, b: 2, c: 3 }; + + omit(subject, 'b', 'c'); + + expect(subject).to.deep.equal({ a: 1, b: 2, c: 3 }); + }); +}); diff --git a/src/utils/test/pick.test.js b/src/utils/test/pick.test.js new file mode 100644 index 00000000..d6d72db6 --- /dev/null +++ b/src/utils/test/pick.test.js @@ -0,0 +1,18 @@ +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import pick from '../pick'; + +describe('util pick()', () => { + it('filters out keys that are not passed as arguments', () => { + expect(pick({ a: 1, b: 2, c: 3 }, 'a', 'c')).to.deep.equal({ a: 1, c: 3 }); + }); + + it('does not mutate the source object', () => { + const subject = { a: 1, b: 2, c: 3 }; + + pick(subject, 'a', 'c'); + + expect(subject).to.deep.equal({ a: 1, b: 2, c: 3 }); + }); +}); diff --git a/src/utils/test/present.test.js b/src/utils/test/present.test.js new file mode 100644 index 00000000..6ae7c7bc --- /dev/null +++ b/src/utils/test/present.test.js @@ -0,0 +1,19 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import present from '../present'; + +describe('util present()', () => { + it('returns false when null is an argument', () => { + expect(present('Test', 0, {}, [], true, false, NaN, null)).to.be.false; + }); + + it('returns false when undefined is an argument', () => { + expect(present('Test', 0, {}, [], true, false, NaN, undefined)).to.be.false; + }); + + it('returns true when there are no null or undefined arguments', () => { + expect(present('Test', 0, {}, [], true, false, NaN)).to.be.true; + }); +}); diff --git a/src/utils/test/promise-hash.test.js b/src/utils/test/promise-hash.test.js new file mode 100644 index 00000000..02637575 --- /dev/null +++ b/src/utils/test/promise-hash.test.js @@ -0,0 +1,33 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import promiseHash from '../promise-hash'; + +describe('util promiseHash()', () => { + it('resolves `Promise`s within an object', () => { + const subject = { + a: 1, + b: Promise.resolve(2), + c: Promise.all([Promise.resolve(3), Promise.resolve(4)]) + }; + + return promiseHash(subject).then(({ a, b, c }) => { + expect(a).to.equal(1); + expect(b).to.equal(2); + expect(c).to.deep.equal([3, 4]); + }); + }); + + it('properly bubbles rejections upward', () => { + const subject = { + a: 1, + b: Promise.reject(new Error('Test')), + c: Promise.all([Promise.resolve(3), Promise.resolve(4)]) + }; + + return promiseHash(subject).catch(err => { + expect(err).to.be.an('error'); + }); + }); +}); diff --git a/src/utils/test/range.test.js b/src/utils/test/range.test.js new file mode 100644 index 00000000..0615d305 --- /dev/null +++ b/src/utils/test/range.test.js @@ -0,0 +1,31 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import range from '../range'; + +describe('util range()', () => { + it('creates an iterable sequence of numbers', () => { + const subject = range(1, 3); + + expect(subject.next()).to.deep.equal({ + done: false, + value: 1 + }); + + expect(subject.next()).to.deep.equal({ + done: false, + value: 2 + }); + + expect(subject.next()).to.deep.equal({ + done: false, + value: 3 + }); + + expect(subject.next()).to.deep.equal({ + done: true, + value: undefined + }); + }); +}); diff --git a/src/utils/test/set-type.test.js b/src/utils/test/set-type.test.js new file mode 100644 index 00000000..c58cac83 --- /dev/null +++ b/src/utils/test/set-type.test.js @@ -0,0 +1,11 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import setType from '../set-type'; + +describe('util setType()', () => { + it('returns the function call of the first and only argument', () => { + expect(setType(() => 'Test')).to.equal('Test'); + }); +}); diff --git a/src/utils/test/stringify.test.js b/src/utils/test/stringify.test.js new file mode 100644 index 00000000..e77c9d65 --- /dev/null +++ b/src/utils/test/stringify.test.js @@ -0,0 +1,42 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import stringify from '../stringify'; + +describe('util stringify()', () => { + it('converts arrays to a string', () => { + const subject = [1, 2, 3]; + + expect(JSON.parse(stringify(subject))).to.deep.equal(subject); + }); + + it('converts booleans to a string', () => { + expect(stringify(true)).to.equal('true'); + expect(stringify(false)).to.equal('false'); + }); + + it('converts null to a string', () => { + expect(stringify(null)).to.equal('null'); + }); + + it('converts numbers to a string', () => { + expect(stringify(1)).to.equal('1'); + expect(stringify(NaN)).to.equal('NaN'); + expect(stringify(Infinity)).to.equal('Infinity'); + }); + + it('converts objects to a string', () => { + const subject = { a: 1, b: 2, c: 3 }; + + expect(JSON.parse(stringify(subject))).to.deep.equal(subject); + }); + + it('converts strings to a string', () => { + expect(stringify('Test')).to.equal('Test'); + }); + + it('converts undefined to a string', () => { + expect(stringify(undefined)).to.equal('undefined'); + }); +}); diff --git a/src/utils/test/transform-keys.test.js b/src/utils/test/transform-keys.test.js new file mode 100644 index 00000000..8048e6b9 --- /dev/null +++ b/src/utils/test/transform-keys.test.js @@ -0,0 +1,259 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import transformKeys, { + camelizeKeys, + dasherizeKeys, + underscoreKeys +} from '../transform-keys'; + +describe('util camelizeKeys()', () => { + const subjectA = { + key_a: 1, // eslint-disable-line camelcase + key_b: 2, // eslint-disable-line camelcase + key_c: 3, // eslint-disable-line camelcase + + key_d: { // eslint-disable-line camelcase + key_a: 1 // eslint-disable-line camelcase + } + }; + + const subjectB = { + 'key-a': 1, + 'key-b': 2, + 'key-c': 3, + + 'key-d': { + 'key-a': 1 + } + }; + + it('can shallow camelize an objects keys', () => { + expect(camelizeKeys(subjectA)).to.deep.equal({ + keyA: 1, + keyB: 2, + keyC: 3, + + keyD: { + key_a: 1 // eslint-disable-line camelcase + } + }); + + expect(camelizeKeys(subjectB)).to.deep.equal({ + keyA: 1, + keyB: 2, + keyC: 3, + + keyD: { + 'key-a': 1 + } + }); + }); + + it('can deep camelize an objects keys', () => { + expect(camelizeKeys(subjectA, true)).to.deep.equal({ + keyA: 1, + keyB: 2, + keyC: 3, + + keyD: { + keyA: 1 + } + }); + + expect(camelizeKeys(subjectB, true)).to.deep.equal({ + keyA: 1, + keyB: 2, + keyC: 3, + + keyD: { + keyA: 1 + } + }); + }); +}); + +describe('util dasherizeKeys()', () => { + const subjectA = { + key_a: 1, // eslint-disable-line camelcase + key_b: 2, // eslint-disable-line camelcase + key_c: 3, // eslint-disable-line camelcase + + key_d: { // eslint-disable-line camelcase + key_a: 1 // eslint-disable-line camelcase + } + }; + + const subjectB = { + keyA: 1, + keyB: 2, + keyC: 3, + + keyD: { + keyA: 1 + } + }; + + it('can shallow dasherize an objects keys', () => { + expect(dasherizeKeys(subjectA)).to.deep.equal({ + 'key-a': 1, + 'key-b': 2, + 'key-c': 3, + + 'key-d': { + key_a: 1 // eslint-disable-line camelcase + } + }); + + expect(dasherizeKeys(subjectB)).to.deep.equal({ + 'key-a': 1, + 'key-b': 2, + 'key-c': 3, + + 'key-d': { + keyA: 1 + } + }); + }); + + it('can deep dasherize an objects keys', () => { + expect(dasherizeKeys(subjectA, true)).to.deep.equal({ + 'key-a': 1, + 'key-b': 2, + 'key-c': 3, + + 'key-d': { + 'key-a': 1 + } + }); + + expect(dasherizeKeys(subjectB, true)).to.deep.equal({ + 'key-a': 1, + 'key-b': 2, + 'key-c': 3, + + 'key-d': { + 'key-a': 1 + } + }); + }); +}); + +describe('util underscoreKeys()', () => { + const subjectA = { + keyA: 1, + keyB: 2, + keyC: 3, + + keyD: { + keyA: 1 + } + }; + + const subjectB = { + 'key-a': 1, + 'key-b': 2, + 'key-c': 3, + + 'key-d': { + 'key-a': 1 + } + }; + + it('can shallow underscore an objects keys', () => { + expect(underscoreKeys(subjectA)).to.deep.equal({ + key_a: 1, // eslint-disable-line camelcase + key_b: 2, // eslint-disable-line camelcase + key_c: 3, // eslint-disable-line camelcase + + key_d: { // eslint-disable-line camelcase + keyA: 1 + } + }); + + expect(underscoreKeys(subjectB)).to.deep.equal({ + key_a: 1, // eslint-disable-line camelcase + key_b: 2, // eslint-disable-line camelcase + key_c: 3, // eslint-disable-line camelcase + + key_d: { // eslint-disable-line camelcase + 'key-a': 1 + } + }); + }); + + it('can deep underscore an objects keys', () => { + expect(underscoreKeys(subjectA, true)).to.deep.equal({ + key_a: 1, // eslint-disable-line camelcase + key_b: 2, // eslint-disable-line camelcase + key_c: 3, // eslint-disable-line camelcase + + key_d: { // eslint-disable-line camelcase + key_a: 1 // eslint-disable-line camelcase + } + }); + + expect(underscoreKeys(subjectB, true)).to.deep.equal({ + key_a: 1, // eslint-disable-line camelcase + key_b: 2, // eslint-disable-line camelcase + key_c: 3, // eslint-disable-line camelcase + + key_d: { // eslint-disable-line camelcase + key_a: 1 // eslint-disable-line camelcase + } + }); + }); +}); + +describe('util transformKeys()', () => { + const subject = { + keyA: 1, + keyB: 2, + keyC: 3, + + keyD: { + keyA: 1 + } + }; + + it('can shallow transform an objects keys', () => { + const result = transformKeys(subject, key => `${key}Transformed`); + + expect(result).to.deep.equal({ + keyATransformed: 1, + keyBTransformed: 2, + keyCTransformed: 3, + + keyDTransformed: { + keyA: 1 + } + }); + }); + + it('can deep transform an objects keys', () => { + const result = transformKeys(subject, key => `${key}Transformed`, true); + + expect(result).to.deep.equal({ + keyATransformed: 1, + keyBTransformed: 2, + keyCTransformed: 3, + + keyDTransformed: { + keyATransformed: 1 + } + }); + }); + + it('does not mutate the source object', () => { + expect(subject).to.deep.equal({ + keyA: 1, + keyB: 2, + keyC: 3, + + keyD: { + keyA: 1 + } + }); + }); +}); diff --git a/src/utils/test/try-catch.test.js b/src/utils/test/try-catch.test.js new file mode 100644 index 00000000..26c01e94 --- /dev/null +++ b/src/utils/test/try-catch.test.js @@ -0,0 +1,38 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import tryCatch, { tryCatchSync } from '../try-catch'; + +describe('util tryCatch()', () => { + it('is a async functional equivalent of try...catch', async () => { + let value = await tryCatch(() => Promise.resolve(false)); + + expect(value).to.be.false; + + await tryCatch(() => { + return Promise.reject(new Error('Test')); + }, () => { + value = true; + }); + + expect(value).to.be.true; + }); +}); + + +describe('util tryCatchSync()', () => { + it('is a functional equivalent of try...catch', () => { + let value = tryCatchSync(() => false); + + expect(value).to.be.false; + + tryCatchSync(() => { + throw new Error('Test'); + }, () => { + value = true; + }); + + expect(value).to.be.true; + }); +}); diff --git a/src/utils/test/underscore.test.js b/src/utils/test/underscore.test.js new file mode 100644 index 00000000..482ccc78 --- /dev/null +++ b/src/utils/test/underscore.test.js @@ -0,0 +1,19 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import underscore from '../underscore'; + +describe('util underscore()', () => { + it('converts ClassName to class_name', () => { + expect(underscore('ClassName')).to.equal('class_name'); + }); + + it('converts camelCase to camel_case', () => { + expect(underscore('camelCase')).to.equal('camel_case'); + }); + + it('converts kebab-case to kebab_case', () => { + expect(underscore('kebab-case')).to.equal('kebab_case'); + }); +}); diff --git a/src/utils/test/uniq.test.js b/src/utils/test/uniq.test.js new file mode 100644 index 00000000..9fc8516c --- /dev/null +++ b/src/utils/test/uniq.test.js @@ -0,0 +1,47 @@ +// @flow +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import uniq from '../uniq'; + +describe('util uniq()', () => { + it('removes duplicate items from an `Array`', () => { + expect(uniq([1, 1, 2, 2, 3, 3])).to.deep.equal([1, 2, 3]); + }); + + it('removes objects with a non-unique key-value pair from an `Array`', () => { + const subject = [ + { + id: 1, + name: 'Test 1' + }, + { + id: 1, + name: 'Test One' + }, + { + id: 2, + name: 'Test 2' + } + ]; + + expect(uniq(subject, 'id')).to.deep.equal([ + { + id: 1, + name: 'Test 1' + }, + { + id: 2, + name: 'Test 2' + } + ]); + }); + + it('does not mutate the source `Array`', () => { + const subject = [1, 1, 2, 2, 3, 3]; + + uniq(subject); + + expect(subject).to.deep.equal([1, 1, 2, 2, 3, 3]); + }); +}); diff --git a/test/index.js b/test/index.js index c5ccf35a..77b9d640 100644 --- a/test/index.js +++ b/test/index.js @@ -1,30 +1,31 @@ -import { join as joinPath, resolve as resolvePath } from 'path'; +// @flow +import { before } from 'mocha'; +import { resolve as resolvePath } from 'path'; import exec from '../src/utils/exec'; import tryCatch from '../src/utils/try-catch'; -before(done => { +import { getTestApp } from './utils/get-test-app'; + +const { env: { APPVEYOR, TRAVIS } } = process; + +before(function (done) { + this.timeout(120000); + process.once('ready', done); tryCatch(async () => { - const path = resolvePath(__dirname, '..', 'test-app'); + const path = resolvePath(__dirname, 'test-app'); const execOpts = { cwd: path }; + if (!APPVEYOR && !TRAVIS) { + await exec('lux db:reset', execOpts); + } + await exec('lux db:migrate', execOpts); await exec('lux db:seed', execOpts); - const { - Application, - config, - database - } = require(joinPath(path, 'dist', 'bundle')); - - await new Application({ - ...config, - database, - path, - port: 4000 - }); + await getTestApp(); }, (err) => { process.removeListener('ready', done); done(err); diff --git a/test/integration/controller.js b/test/integration/controller.js deleted file mode 100644 index 1ee1dde9..00000000 --- a/test/integration/controller.js +++ /dev/null @@ -1,530 +0,0 @@ -import { expect } from 'chai'; - -import fetch from '../utils/fetch'; - -const host = 'http://localhost:4000'; - -describe('Integration: class Controller', () => { - let createdId; - - describe('#index()', () => { - let subject, payload; - - before(async () => { - subject = await fetch(`${host}/posts`); - payload = await subject.json(); - }); - - it('has 200 status code', () => { - expect(subject.status).to.equal(200); - }); - - it('has JSON API `Content-Type` header', () => { - expect( - subject.headers.get('Content-Type') - ).to.equal('application/vnd.api+json'); - }); - - it('returns a list of JSON API resource objects', () => { - let item; - - expect(payload.data).to.be.an.instanceOf(Array); - - for (item of payload.data) { - expect(item).to.have.all.keys( - 'type', - 'id', - 'links', - 'attributes', - 'relationships' - ); - } - }); - - it('has a links with a reference to `self`', () => { - expect(payload.links) - .to.have.property('self') - .and.equal(`${host}/posts`); - }); - - describe('pagination', () => { - let pagesWithLimit, pagesWithoutLimit; - - before(async () => { - pagesWithLimit = await Promise.all([ - ...[1, 2, 4, 5, 6].map(async (page) => { - page = await fetch( - `${host}/posts?page%5Bsize%5D=10&page%5Bnumber%5D=${page}` - ); - - return { - subject: page, - payload: await page.json() - }; - }) - ]); - - pagesWithoutLimit = await Promise.all([ - ...[1, 2, 3].map(async (page) => { - page = await fetch(`${host}/posts?page%5Bnumber%5D=${page}`); - - return { - subject: page, - payload: await page.json() - }; - }) - ]); - }); - - it('has 200 status code', () => { - expect( - [...pagesWithLimit, ...pagesWithoutLimit].some(page => { - return page.subject.status !== 200; - }) - ).to.be.false; - }); - - it('supports page[size] parameter', () => { - let page; - - for (page of pagesWithLimit) { - expect(page.payload.data).to.have.length.within(0, 10); - } - }); - - it('has a default page[size] of 25', () => { - let page; - - for (page of pagesWithoutLimit) { - expect(page.payload.data).to.have.length.within(0, 25); - } - }); - - it('has [self, first, last, prev, next] links', () => { - let page; - - for (page of pagesWithLimit) { - expect(page.payload.links) - .to.have.all.keys('self', 'first', 'last', 'prev', 'next'); - } - - for (page of pagesWithoutLimit) { - expect(page.payload.links) - .to.have.all.keys('self', 'first', 'last', 'prev', 'next'); - } - }); - }); - - describe('sorting', () => { - let asc, desc; - - before(async () => { - [asc, desc] = await Promise.all([ - ...[ - fetch(`${host}/posts?sort=title`), - fetch(`${host}/posts?sort=-title`) - ].map(async (req) => { - const res = await req; - - return { - subject: res, - payload: await res.json() - }; - }) - ]); - }); - - it('has 200 status code', () => { - expect(asc.subject.status).to.equal(200); - expect(desc.subject.status).to.equal(200); - }); - - it('can sort in ASCENDING order', () => { - const { payload: { data: [{ attributes: { title } }] } } = asc; - - expect(title).to.equal('New Post 1'); - }); - - it('can sort in DESCENDING order', () => { - const { payload: { data: [{ attributes: { title } }] } } = desc; - - expect(title).to.equal('New Post 9'); - }); - }); - - describe('filtering', () => { - let filtered; - - before(async () => { - const res = await fetch(`${host}/posts?${encodeURIComponent('filter[title]')}=${encodeURIComponent('New Post 1')}`); - - filtered = { - subject: res, - payload: await res.json() - }; - }); - - it('has 200 status code', () => { - expect(filtered.subject.status).to.equal(200); - }); - - it('works as expected', () => { - const { - payload: { - data: [{ attributes: { title } }] - } - } = filtered; - - expect(filtered.payload.data).to.have.length(1); - expect(title).to.equal('New Post 1'); - }); - }); - - describe('including related resources', () => { - let included; - - before(async () => { - const req = await fetch(`${host}/posts?include=author`); - - included = { - subject: req, - payload: await req.json() - }; - }); - - it('has 200 status code', () => { - expect(included.subject.status).to.equal(200); - }); - - it('works as expected', () => { - let item; - - expect(included.payload.included).to.be.an.instanceOf(Array); - - for (item of included.payload.included) { - expect(item).to.have.all.keys( - 'type', - 'id', - 'links', - 'attributes' - ); - - expect(item.type).to.equal('authors'); - } - }); - }); - - describe('sparse fieldsets', () => { - let included, excluded; - - before(async () => { - [included, excluded] = await Promise.all([ - ...[ - fetch(`${host}/posts?include=author&${encodeURIComponent('fields[posts]')}=title&${encodeURIComponent('fields[authors]')}=name`), - fetch(`${host}/posts?${encodeURIComponent('fields[posts]')}=title`) - ].map(async (req) => { - const res = await req; - - return { - subject: res, - payload: await res.json() - }; - }) - ]); - }); - - it('has 200 status code', () => { - expect(included.subject.status).to.equal(200); - expect(excluded.subject.status).to.equal(200); - }); - - it('works with main resource', () => { - let item; - - for (item of excluded.payload.data) { - expect(item.attributes).to.have.all.keys('title'); - } - }); - - it('works with included resources', () => { - let item; - - for (item of included.payload.data) { - expect(item.attributes).to.have.all.keys('title'); - } - - for (item of included.payload.included) { - expect(item.type).to.equal('authors'); - expect(item.attributes).to.have.all.keys('name'); - } - }); - }); - }); - - describe('#show()', () => { - let subject, payload; - - before(async () => { - subject = await fetch(`${host}/posts/1`); - payload = await subject.json(); - }); - - it('has 200 status code', () => { - expect(subject.status).to.equal(200); - }); - - it('has JSON API `Content-Type` header', () => { - expect( - subject.headers.get('Content-Type') - ).to.equal('application/vnd.api+json'); - }); - - it('returns a JSON API resource object', () => { - expect(payload.data).to.have.all.keys( - 'type', - 'id', - 'attributes', - 'relationships' - ); - }); - - describe('including related resources', () => { - let included; - - before(async () => { - const req = await fetch(`${host}/posts/1?include=author`); - - included = { - subject: req, - payload: await req.json() - }; - }); - - it('has 200 status code', () => { - expect(included.subject.status).to.equal(200); - }); - - it('works as expected', () => { - let item; - - expect(included.payload.included).to.be.an.instanceOf(Array); - - for (item of included.payload.included) { - expect(item).to.have.all.keys( - 'type', - 'id', - 'links', - 'attributes' - ); - - expect(item.type).to.equal('authors'); - } - }); - }); - - describe('sparse fieldsets', () => { - let included, excluded; - - before(async () => { - [included, excluded] = await Promise.all([ - ...[ - fetch(`${host}/posts/1?include=author&${encodeURIComponent('fields[posts]')}=title&${encodeURIComponent('fields[authors]')}=name`), - fetch(`${host}/posts/1?${encodeURIComponent('fields[posts]')}=title`) - ].map(async (req) => { - const res = await req; - - return { - subject: res, - payload: await res.json() - }; - }) - ]); - }); - - it('has 200 status code', () => { - expect(included.subject.status).to.equal(200); - expect(excluded.subject.status).to.equal(200); - }); - - it('works with main resource', () => { - expect(excluded.payload.data.attributes).to.have.all.keys('title'); - }); - - it('works with included resources', () => { - let item; - - expect(included.payload.data.attributes).to.have.all.keys('title'); - - for (item of included.payload.included) { - expect(item.type).to.equal('authors'); - expect(item.attributes).to.have.all.keys('name'); - } - }); - }); - }); - - describe('#create()', () => { - let subject - let payload; - - before(async () => { - subject = await fetch(`${host}/posts`, { - method: 'POST', - body: JSON.stringify({ - data: { - type: 'posts', - attributes: { - title: 'Not another Node.js framework…', - body: 'A few years ago I was working for a very lean web start up...', - 'is-public': false - } - } - }) - }); - - payload = await subject.json(); - createdId = payload.data.id; - }); - - it('has 201 status code', () => { - expect(subject.status).to.equal(201); - }); - - it('has JSON API `Content-Type` header', () => { - expect( - subject.headers.get('Content-Type') - ).to.equal('application/vnd.api+json'); - }); - - it('returns a JSON API resource object', () => { - expect(payload.data).to.have.all.keys( - 'type', - 'id', - 'attributes', - 'relationships' - ); - }); - }); - - describe('#update()', () => { - let subject, payload; - - before(async () => { - subject = await fetch(`${host}/posts/${createdId}`, { - method: 'PATCH', - body: JSON.stringify({ - data: { - id: `${createdId}`, - type: 'posts', - attributes: { - 'is-public': true - } - } - }) - }); - - payload = await subject.json(); - }); - - it('has 200 status code', () => { - expect(subject.status).to.equal(200); - }); - - it('has JSON API `Content-Type` header', () => { - expect( - subject.headers.get('Content-Type') - ).to.equal('application/vnd.api+json'); - }); - - it('returns a JSON API resource object', () => { - expect(payload.data).to.have.all.keys( - 'type', - 'id', - 'attributes', - 'relationships' - ); - }); - }); - - describe('#destroy()', () => { - let subject; - - before(async () => { - subject = await fetch(`${host}/posts/${createdId}`, { - method: 'DELETE' - }); - }); - - it('has 204 status code', () => { - expect(subject.status).to.equal(204); - }); - }); - - describe('#preflight()', () => { - let subject; - - before(async () => { - subject = await fetch(`${host}/posts`, { - method: 'OPTIONS' - }); - }); - - it('has 204 status code', () => { - expect(subject.status).to.equal(204); - }); - }); - - describe('Regression: #middleware (https://github.com/postlight/lux/issues/94)', () => { - let subject; - - before(async () => { - subject = await fetch(`${host}/posts`); - }); - - it('includes middleware from it\'s `parentController`', () => { - expect( - subject.headers.get('X-Powered-By') - ).to.equal('Lux'); - }); - - it('includes middleware defined in `beforeAction`', () => { - expect( - subject.headers.get('X-Controller') - ).to.equal('Posts'); - }); - }); - - describe('Regression: #createPageLinks (https://github.com/postlight/lux/issues/102)', () => { - let url, subject, payload; - - before(async () => { - url = `${host}/tags`; - subject = await fetch(url); - payload = await subject.json(); - }); - - it('has the expected `links` value', () => { - expect(payload.links).to.deep.equal({ - self: url, - first: url, - last: url, - prev: null, - next: null - }); - }); - }); - - /** - * This is a VERY basic test to show that namespaces are working. - * - * TODO: Add more meaningful namespace tests as a part of - * https://github.com/postlight/lux/pull/287. - */ - describe('Namespaces', () => { - it('works as expected', async () => { - const subject = await fetch(`${host}/admin/posts`); - - expect(subject.headers.get('X-Namespace')).to.equal('admin'); - }); - }); -}); diff --git a/test/integration/serializer.js b/test/integration/serializer.js deleted file mode 100644 index d4a2c39f..00000000 --- a/test/integration/serializer.js +++ /dev/null @@ -1,63 +0,0 @@ -import { expect } from 'chai'; - -import fetch from '../utils/fetch'; - -const host = 'http://localhost:4000'; - -describe('Integration: class Serializer', () => { - let subject; - - before(async () => { - subject = await (await fetch(`${host}/posts/1?include=author`)).json(); - }); - - it('serializes type', () => { - const { data: { type } } = subject; - - expect(type).to.equal('posts'); - }); - - it('serializes id', () => { - const { data: { id } } = subject; - - expect(id).to.equal('1'); - }); - - it('serializes attributes', () => { - const { data: { attributes } } = subject; - - expect(attributes).to.have.all.keys( - 'title', - 'body', - 'created-at', - 'updated-at' - ); - }); - - it('serializes relationships', () => { - const { data: { relationships } } = subject; - - expect(relationships) - .to.have.property('author') - .and.to.have.all.keys('data', 'links'); - - expect(relationships.author.data).to.have.all.keys('type', 'id'); - expect(relationships.author.links).to.have.all.keys('self'); - }); - - it('serializes included resources', () => { - const { included: [author] } = subject; - - expect(author).to.have.all.keys('id', 'type', 'attributes', 'links'); - expect(author.links).to.have.all.keys('self'); - expect(author.attributes).to.have.all.keys('name', 'created-at'); - - expect(author.type).to.equal('authors'); - }); - - it('includes JSON API version', () => { - const { jsonapi: { version } } = subject; - - expect(version).to.equal('1.0'); - }); -}); diff --git a/test/test-app/app/controllers/actions.js b/test/test-app/app/controllers/actions.js new file mode 100644 index 00000000..56c834bc --- /dev/null +++ b/test/test-app/app/controllers/actions.js @@ -0,0 +1,7 @@ +import { Controller } from 'LUX_LOCAL'; + +class ActionsController extends Controller { + +} + +export default ActionsController; diff --git a/test/test-app/app/controllers/admin/actions.js b/test/test-app/app/controllers/admin/actions.js new file mode 100644 index 00000000..4abc0cea --- /dev/null +++ b/test/test-app/app/controllers/admin/actions.js @@ -0,0 +1,7 @@ +import ActionsController from '../actions'; + +class AdminActionsController extends ActionsController { + +} + +export default AdminActionsController; diff --git a/test/test-app/app/controllers/admin/application.js b/test/test-app/app/controllers/admin/application.js index 481731f5..66e0faf1 100644 --- a/test/test-app/app/controllers/admin/application.js +++ b/test/test-app/app/controllers/admin/application.js @@ -1,11 +1,7 @@ -import ApplicationController from 'app/controllers/application.js'; +import ApplicationController from '../application'; class AdminApplicationController extends ApplicationController { - beforeAction = [ - function setNamespaceHeader(req, res) { - res.setHeader('X-Namespace', 'admin'); - } - ]; + } export default AdminApplicationController; diff --git a/test/test-app/app/controllers/admin/authors.js b/test/test-app/app/controllers/admin/authors.js deleted file mode 100644 index a90fc059..00000000 --- a/test/test-app/app/controllers/admin/authors.js +++ /dev/null @@ -1,7 +0,0 @@ -import AuthorsController from 'app/controllers/authors.js'; - -class AdminAuthorsController extends AuthorsController { - -} - -export default AdminAuthorsController; diff --git a/test/test-app/app/controllers/admin/categorizations.js b/test/test-app/app/controllers/admin/categorizations.js new file mode 100644 index 00000000..891020f5 --- /dev/null +++ b/test/test-app/app/controllers/admin/categorizations.js @@ -0,0 +1,7 @@ +import CategorizationsController from '../categorizations'; + +class AdminCategorizationsController extends CategorizationsController { + +} + +export default AdminCategorizationsController; diff --git a/test/test-app/app/controllers/admin/comments.js b/test/test-app/app/controllers/admin/comments.js new file mode 100644 index 00000000..3009e0d1 --- /dev/null +++ b/test/test-app/app/controllers/admin/comments.js @@ -0,0 +1,7 @@ +import CommentsController from '../comments'; + +class AdminCommentsController extends CommentsController { + +} + +export default AdminCommentsController; diff --git a/test/test-app/app/controllers/admin/friendships.js b/test/test-app/app/controllers/admin/friendships.js new file mode 100644 index 00000000..40640aab --- /dev/null +++ b/test/test-app/app/controllers/admin/friendships.js @@ -0,0 +1,7 @@ +import FriendshipsController from '../friendships'; + +class AdminFriendshipsController extends FriendshipsController { + +} + +export default AdminFriendshipsController; diff --git a/test/test-app/app/controllers/admin/notifications.js b/test/test-app/app/controllers/admin/notifications.js new file mode 100644 index 00000000..1788491e --- /dev/null +++ b/test/test-app/app/controllers/admin/notifications.js @@ -0,0 +1,7 @@ +import NotificationsController from '../notifications'; + +class AdminNotificationsController extends NotificationsController { + +} + +export default AdminNotificationsController; diff --git a/test/test-app/app/controllers/admin/posts.js b/test/test-app/app/controllers/admin/posts.js index ab5c8e30..bf919faa 100644 --- a/test/test-app/app/controllers/admin/posts.js +++ b/test/test-app/app/controllers/admin/posts.js @@ -1,13 +1,9 @@ -import PostsController from 'app/controllers/posts.js'; +import PostsController from '../posts'; class AdminPostsController extends PostsController { index(req, res) { return super.index(req, res).unscope('isPublic'); } - - show(req, res) { - return super.show(req, res).unscope('isPublic'); - } } export default AdminPostsController; diff --git a/test/test-app/app/controllers/admin/reactions.js b/test/test-app/app/controllers/admin/reactions.js new file mode 100644 index 00000000..816187a9 --- /dev/null +++ b/test/test-app/app/controllers/admin/reactions.js @@ -0,0 +1,7 @@ +import ReactionsController from '../reactions'; + +class AdminReactionsController extends ReactionsController { + +} + +export default AdminReactionsController; diff --git a/test/test-app/app/controllers/admin/tags.js b/test/test-app/app/controllers/admin/tags.js index 0b4b7f6e..da064bb9 100644 --- a/test/test-app/app/controllers/admin/tags.js +++ b/test/test-app/app/controllers/admin/tags.js @@ -1,4 +1,4 @@ -import TagsController from 'app/controllers/tags.js'; +import TagsController from '../tags'; class AdminTagsController extends TagsController { diff --git a/test/test-app/app/controllers/admin/users.js b/test/test-app/app/controllers/admin/users.js new file mode 100644 index 00000000..9936a966 --- /dev/null +++ b/test/test-app/app/controllers/admin/users.js @@ -0,0 +1,7 @@ +import UsersController from '../users'; + +class AdminUsersController extends UsersController { + +} + +export default AdminUsersController; diff --git a/test/test-app/app/controllers/application.js b/test/test-app/app/controllers/application.js index 6254999f..b8c00ce6 100644 --- a/test/test-app/app/controllers/application.js +++ b/test/test-app/app/controllers/application.js @@ -1,11 +1,7 @@ import { Controller } from 'LUX_LOCAL'; class ApplicationController extends Controller { - beforeAction = [ - function setPoweredByHeader(req, res) { - res.setHeader('X-Powered-By', 'Lux'); - } - ]; + } export default ApplicationController; diff --git a/test/test-app/app/controllers/authors.js b/test/test-app/app/controllers/authors.js deleted file mode 100644 index 2a9c635d..00000000 --- a/test/test-app/app/controllers/authors.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Controller } from 'LUX_LOCAL'; - -class AuthorsController extends Controller { - params = [ - 'name', - 'posts' - ]; - - beforeAction = [ - function setControllerHeader(req, res) { - res.setHeader('X-Controller', 'Authors'); - } - ]; -} - -export default AuthorsController; diff --git a/test/test-app/app/controllers/categorizations.js b/test/test-app/app/controllers/categorizations.js new file mode 100644 index 00000000..f5b9e66c --- /dev/null +++ b/test/test-app/app/controllers/categorizations.js @@ -0,0 +1,10 @@ +import { Controller } from 'LUX_LOCAL'; + +class CategorizationsController extends Controller { + params = [ + 'tag', + 'post' + ]; +} + +export default CategorizationsController; diff --git a/test/test-app/app/controllers/comments.js b/test/test-app/app/controllers/comments.js new file mode 100644 index 00000000..d8c52c6f --- /dev/null +++ b/test/test-app/app/controllers/comments.js @@ -0,0 +1,12 @@ +import { Controller } from 'LUX_LOCAL'; + +class CommentsController extends Controller { + params = [ + 'post', + 'user', + 'edited', + 'message' + ]; +} + +export default CommentsController; diff --git a/test/test-app/app/controllers/friendships.js b/test/test-app/app/controllers/friendships.js new file mode 100644 index 00000000..6fa655e0 --- /dev/null +++ b/test/test-app/app/controllers/friendships.js @@ -0,0 +1,10 @@ +import { Controller } from 'LUX_LOCAL'; + +class FriendshipsController extends Controller { + params = [ + 'followee', + 'follower' + ]; +} + +export default FriendshipsController; diff --git a/test/test-app/app/controllers/health.js b/test/test-app/app/controllers/health.js new file mode 100644 index 00000000..3d3af64e --- /dev/null +++ b/test/test-app/app/controllers/health.js @@ -0,0 +1,7 @@ +import { Controller } from 'LUX_LOCAL'; + +class HealthController extends Controller { + index = () => 204; +} + +export default HealthController; diff --git a/test/test-app/app/controllers/notifications.js b/test/test-app/app/controllers/notifications.js new file mode 100644 index 00000000..4025a84c --- /dev/null +++ b/test/test-app/app/controllers/notifications.js @@ -0,0 +1,7 @@ +import { Controller } from 'LUX_LOCAL'; + +class NotificationsController extends Controller { + +} + +export default NotificationsController; diff --git a/test/test-app/app/controllers/posts.js b/test/test-app/app/controllers/posts.js index d9e3c921..411a5f37 100644 --- a/test/test-app/app/controllers/posts.js +++ b/test/test-app/app/controllers/posts.js @@ -2,25 +2,15 @@ import { Controller } from 'LUX_LOCAL'; class PostsController extends Controller { params = [ + 'user', 'body', 'title', - 'author', 'isPublic' ]; - beforeAction = [ - function setControllerHeader(req, res) { - res.setHeader('X-Controller', 'Posts'); - } - ]; - index(req, res) { return super.index(req, res).isPublic(); } - - show(req, res) { - return super.show(req, res).isPublic(); - } } export default PostsController; diff --git a/test/test-app/app/controllers/reactions.js b/test/test-app/app/controllers/reactions.js new file mode 100644 index 00000000..6f948023 --- /dev/null +++ b/test/test-app/app/controllers/reactions.js @@ -0,0 +1,12 @@ +import { Controller } from 'LUX_LOCAL'; + +class ReactionsController extends Controller { + params = [ + 'type', + 'user', + 'post', + 'comment' + ]; +} + +export default ReactionsController; diff --git a/test/test-app/app/controllers/users.js b/test/test-app/app/controllers/users.js new file mode 100644 index 00000000..58087318 --- /dev/null +++ b/test/test-app/app/controllers/users.js @@ -0,0 +1,26 @@ +import { Controller } from 'LUX_LOCAL'; + +import User from '../models/user'; + +class UsersController extends Controller { + params = [ + 'name', + 'email', + 'password' + ]; + + login({ + params: { + data: { + attributes: { + email, + password + } + } + } + }) { + return User.authenticate(email, password); + } +} + +export default UsersController; diff --git a/test/test-app/app/models/action.js b/test/test-app/app/models/action.js new file mode 100644 index 00000000..943aa787 --- /dev/null +++ b/test/test-app/app/models/action.js @@ -0,0 +1,89 @@ +import { Model } from 'LUX_LOCAL'; + +import Comment from './comment'; +import Notification from './notification'; +import Post from './post'; +import Reaction from './reaction'; +import User from './user'; + +/* TODO: Add support for polymorphic relationship to a 'trackable'. + * https://github.com/postlight/lux/issues/75 + */ +class Action extends Model { + static hooks = { + async afterCreate(action) { + await action.notifyOwner(); + } + }; + + async notifyOwner() { + const { trackableId, trackableType } = this; + let params; + + if (trackableType === 'Comment') { + const { + postId, + userId + } = await Comment.find(trackableId, { + select: ['postId', 'userId'] + }); + + const [ + { name: userName }, + { userId: recipientId } + ] = await Promise.all([ + User.find(userId, { select: ['name'] }), + Post.find(postId, { select: ['userId'] }) + ]); + + params = { + recipientId, + message: `${userName} commented on your post!` + }; + } else if (trackableType === 'Reaction') { + let reactableId; + let reactableModel = Post; + + const { + commentId, + postId, + type, + userId + } = await Reaction.find(trackableId, { + select: [ + 'commentId', + 'postId', + 'type', + 'userId' + ] + }); + + if (!postId) { + reactableId = commentId; + reactableModel = Comment; + } else { + reactableId = postId; + } + + const [ + { name: userName }, + { userId: recipientId } + ] = await Promise.all([ + User.find(userId, { select: ['name'] }), + reactableModel.find(reactableId, { select: ['userId'] }) + ]); + + params = { + recipientId, + message: `${userName} reacted with ${type} to your ` + + `${reactableModel.name.toLowerCase()}!` + }; + } + + if (params) { + return await Notification.create(params); + } + } +} + +export default Action; diff --git a/test/test-app/app/models/author.js b/test/test-app/app/models/author.js deleted file mode 100644 index 27d5f518..00000000 --- a/test/test-app/app/models/author.js +++ /dev/null @@ -1,11 +0,0 @@ -import { Model } from 'LUX_LOCAL'; - -class Author extends Model { - static hasMany = { - posts: { - inverse: 'author' - } - }; -} - -export default Author; diff --git a/test/test-app/app/models/categorization.js b/test/test-app/app/models/categorization.js new file mode 100644 index 00000000..c6bdea5d --- /dev/null +++ b/test/test-app/app/models/categorization.js @@ -0,0 +1,15 @@ +import { Model } from 'LUX_LOCAL'; + +class Categorization extends Model { + static belongsTo = { + tag: { + inverse: 'posts' + }, + + post: { + inverse: 'tags' + } + }; +} + +export default Categorization; diff --git a/test/test-app/app/models/comment.js b/test/test-app/app/models/comment.js new file mode 100644 index 00000000..b0ed1c06 --- /dev/null +++ b/test/test-app/app/models/comment.js @@ -0,0 +1,33 @@ +import { Model } from 'LUX_LOCAL'; + +import track from '../utils/track'; + +class Comment extends Model { + static belongsTo = { + post: { + inverse: 'comments' + }, + + user: { + inverse: 'comments' + } + }; + + static hasMany = { + actions: { + inverse: 'trackable' + }, + + reactions: { + inverse: 'comment' + } + }; + + static hooks = { + async afterCreate(comment) { + await track(comment); + } + }; +} + +export default Comment; diff --git a/test/test-app/app/models/friendship.js b/test/test-app/app/models/friendship.js new file mode 100644 index 00000000..38f71e1f --- /dev/null +++ b/test/test-app/app/models/friendship.js @@ -0,0 +1,17 @@ +import { Model } from 'LUX_LOCAL'; + +class Friendship extends Model { + static belongsTo = { + follower: { + model: 'user', + inverse: 'followers' + }, + + followee: { + model: 'user', + inverse: 'followees' + } + }; +} + +export default Friendship; diff --git a/test/test-app/app/models/notification.js b/test/test-app/app/models/notification.js new file mode 100644 index 00000000..13930140 --- /dev/null +++ b/test/test-app/app/models/notification.js @@ -0,0 +1,12 @@ +import { Model } from 'LUX_LOCAL'; + +class Notification extends Model { + static belongsTo = { + recipient: { + model: 'user', + inverse: 'notifications' + } + }; +} + +export default Notification; diff --git a/test/test-app/app/models/post.js b/test/test-app/app/models/post.js index 05d11307..8e8536ff 100644 --- a/test/test-app/app/models/post.js +++ b/test/test-app/app/models/post.js @@ -1,19 +1,36 @@ import { Model } from 'LUX_LOCAL'; +import track from '../utils/track'; + class Post extends Model { static belongsTo = { - author: { + user: { inverse: 'posts' } }; - static scopes = { - drafts() { - return this.where({ - isPublic: false - }); + static hasMany = { + comments: { + inverse: 'post' }, + reactions: { + inverse: 'post' + }, + + tags: { + inverse: 'posts', + through: 'categorization' + } + }; + + static hooks = { + async afterCreate(post) { + await track(post); + } + }; + + static scopes = { isPublic() { return this.where({ isPublic: true diff --git a/test/test-app/app/models/reaction.js b/test/test-app/app/models/reaction.js new file mode 100644 index 00000000..b2b57bd4 --- /dev/null +++ b/test/test-app/app/models/reaction.js @@ -0,0 +1,38 @@ +import { Model } from 'LUX_LOCAL'; + +import track from '../utils/track'; + +class Reaction extends Model { + static belongsTo = { + comment: { + inverse: 'reactions' + }, + + post: { + inverse: 'reactions' + }, + + user: { + inverse: 'reactions' + } + }; + + static hooks = { + beforeSave(reaction) { + const { + commentId, + postId + } = reaction; + + if (!commentId && !postId) { + throw new Error('Reactions must have a reactable (Post or Comment).'); + } + }, + + async afterCreate(reaction) { + await track(reaction); + } + }; +} + +export default Reaction; diff --git a/test/test-app/app/models/tag.js b/test/test-app/app/models/tag.js index 0a11ce4b..45b56fd6 100644 --- a/test/test-app/app/models/tag.js +++ b/test/test-app/app/models/tag.js @@ -1,7 +1,12 @@ import { Model } from 'LUX_LOCAL'; class Tag extends Model { - + static hasMany = { + posts: { + inverse: 'tags', + through: 'categorization' + } + }; } export default Tag; diff --git a/test/test-app/app/models/user.js b/test/test-app/app/models/user.js new file mode 100644 index 00000000..fc08294f --- /dev/null +++ b/test/test-app/app/models/user.js @@ -0,0 +1,69 @@ +import { Model } from 'LUX_LOCAL'; + +import { hashPassword, comparePassword } from '../utils/password'; + +class User extends Model { + static hasMany = { + comments: { + inverse: 'user' + }, + + notifications: { + inverse: 'recipient' + }, + + posts: { + inverse: 'user' + }, + + followees: { + model: 'user', + inverse: 'followers', + through: 'friendship' + }, + + followers: { + model: 'user', + inverse: 'followees', + through: 'friendship' + }, + + reactions: { + inverse: 'user' + } + }; + + static hooks = { + async beforeSave(user) { + if (user.isNew || user.dirtyAttributes.has('password')) { + user.password = await hashPassword(user.password); + } + } + }; + + static scopes = { + findByEmail(email) { + return this.first().where({ + email + }); + } + }; + + static validates = { + password(password = '') { + return password.length >= 8; + } + }; + + static async authenticate(email, password) { + const user = await this.findByEmail(email); + + if (user) { + const isAuthenticated = await comparePassword(password, user.password); + + return isAuthenticated && user; + } + } +} + +export default User; diff --git a/test/test-app/app/routes.js b/test/test-app/app/routes.js index 40841dd3..7e6fee14 100644 --- a/test/test-app/app/routes.js +++ b/test/test-app/app/routes.js @@ -1,11 +1,40 @@ export default function routes() { - this.resource('authors'); + this.resource('actions', { + only: ['show', 'index'] + }); + + this.resource('comments'); + + this.resource('friendships', { + only: ['create', 'destroy'] + }); + + this.resource('health', { + only: ['index'] + }); + + this.resource('notifications', { + only: ['show', 'index'] + }); + this.resource('posts'); + this.resource('reactions'); this.resource('tags'); + this.resource('users', function () { + this.collection(function () { + this.post('login'); + }); + }); + this.namespace('admin', function () { - this.resource('authors'); + this.resource('actions'); + this.resource('comments'); + this.resource('friendships'); + this.resource('notifications'); this.resource('posts'); + this.resource('reactions'); this.resource('tags'); + this.resource('users'); }); } diff --git a/test/test-app/app/serializers/actions.js b/test/test-app/app/serializers/actions.js new file mode 100644 index 00000000..de2e2b25 --- /dev/null +++ b/test/test-app/app/serializers/actions.js @@ -0,0 +1,7 @@ +import { Serializer } from 'LUX_LOCAL'; + +class ActionsSerializer extends Serializer { + +} + +export default ActionsSerializer; diff --git a/test/test-app/app/serializers/admin/actions.js b/test/test-app/app/serializers/admin/actions.js new file mode 100644 index 00000000..7acc7f05 --- /dev/null +++ b/test/test-app/app/serializers/admin/actions.js @@ -0,0 +1,7 @@ +import ActionsSerializer from '../actions'; + +class AdminActionsSerializer extends ActionsSerializer { + +} + +export default AdminActionsSerializer; diff --git a/test/test-app/app/serializers/admin/application.js b/test/test-app/app/serializers/admin/application.js index 7f634b63..3abf3c19 100644 --- a/test/test-app/app/serializers/admin/application.js +++ b/test/test-app/app/serializers/admin/application.js @@ -1,4 +1,4 @@ -import ApplicationSerializer from 'app/serializers/application.js'; +import ApplicationSerializer from '../application'; class AdminApplicationSerializer extends ApplicationSerializer { diff --git a/test/test-app/app/serializers/admin/authors.js b/test/test-app/app/serializers/admin/authors.js deleted file mode 100644 index 0234868d..00000000 --- a/test/test-app/app/serializers/admin/authors.js +++ /dev/null @@ -1,11 +0,0 @@ -import AuthorsSerializer from 'app/serializers/authors.js'; - -class AdminAuthorsSerializer extends AuthorsSerializer { - attributes = [ - 'name', - 'createdAt', - 'updatedAt' - ]; -} - -export default AdminAuthorsSerializer; diff --git a/test/test-app/app/serializers/admin/categorizations.js b/test/test-app/app/serializers/admin/categorizations.js new file mode 100644 index 00000000..e6015e47 --- /dev/null +++ b/test/test-app/app/serializers/admin/categorizations.js @@ -0,0 +1,7 @@ +import CategorizationsSerializer from '../categorizations'; + +class AdminCategorizationsSerializer extends CategorizationsSerializer { + +} + +export default AdminCategorizationsSerializer; diff --git a/test/test-app/app/serializers/admin/comments.js b/test/test-app/app/serializers/admin/comments.js new file mode 100644 index 00000000..22d6c876 --- /dev/null +++ b/test/test-app/app/serializers/admin/comments.js @@ -0,0 +1,7 @@ +import CommentsSerializer from '../comments'; + +class AdminCommentsSerializer extends CommentsSerializer { + +} + +export default AdminCommentsSerializer; diff --git a/test/test-app/app/serializers/admin/friendships.js b/test/test-app/app/serializers/admin/friendships.js new file mode 100644 index 00000000..b6c56f98 --- /dev/null +++ b/test/test-app/app/serializers/admin/friendships.js @@ -0,0 +1,7 @@ +import FriendshipsSerializer from '../friendships'; + +class AdminFriendshipsSerializer extends FriendshipsSerializer { + +} + +export default AdminFriendshipsSerializer; diff --git a/test/test-app/app/serializers/admin/notifications.js b/test/test-app/app/serializers/admin/notifications.js new file mode 100644 index 00000000..62764514 --- /dev/null +++ b/test/test-app/app/serializers/admin/notifications.js @@ -0,0 +1,7 @@ +import NotificationsSerializer from '../notifications'; + +class AdminNotificationsSerializer extends NotificationsSerializer { + +} + +export default AdminNotificationsSerializer; diff --git a/test/test-app/app/serializers/admin/posts.js b/test/test-app/app/serializers/admin/posts.js index 4181b31e..ec417bea 100644 --- a/test/test-app/app/serializers/admin/posts.js +++ b/test/test-app/app/serializers/admin/posts.js @@ -1,13 +1,7 @@ -import PostsSerializer from 'app/serializers/posts.js'; +import PostsSerializer from '../posts'; class AdminPostsSerializer extends PostsSerializer { - attributes = [ - 'body', - 'title', - 'isPublic', - 'createdAt', - 'updatedAt' - ]; + } export default AdminPostsSerializer; diff --git a/test/test-app/app/serializers/admin/reactions.js b/test/test-app/app/serializers/admin/reactions.js new file mode 100644 index 00000000..56aca380 --- /dev/null +++ b/test/test-app/app/serializers/admin/reactions.js @@ -0,0 +1,7 @@ +import ReactionsSerializer from '../reactions'; + +class AdminReactionsSerializer extends ReactionsSerializer { + +} + +export default AdminReactionsSerializer; diff --git a/test/test-app/app/serializers/admin/tags.js b/test/test-app/app/serializers/admin/tags.js index 776faa95..d5441bc0 100644 --- a/test/test-app/app/serializers/admin/tags.js +++ b/test/test-app/app/serializers/admin/tags.js @@ -1,11 +1,7 @@ -import TagsSerializer from 'app/serializers/tags.js'; +import TagsSerializer from '../tags'; class AdminTagsSerializer extends TagsSerializer { - attributes = [ - 'name', - 'createdAt', - 'updatedAt' - ]; + } export default AdminTagsSerializer; diff --git a/test/test-app/app/serializers/admin/users.js b/test/test-app/app/serializers/admin/users.js new file mode 100644 index 00000000..be1fd536 --- /dev/null +++ b/test/test-app/app/serializers/admin/users.js @@ -0,0 +1,7 @@ +import UsersSerializer from '../users'; + +class AdminUsersSerializer extends UsersSerializer { + +} + +export default AdminUsersSerializer; diff --git a/test/test-app/app/serializers/authors.js b/test/test-app/app/serializers/authors.js deleted file mode 100644 index d8da6c41..00000000 --- a/test/test-app/app/serializers/authors.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Serializer } from 'LUX_LOCAL'; - -class AuthorsSerializer extends Serializer { - attributes = [ - 'name', - 'createdAt' - ]; - - hasMany = [ - 'posts' - ]; -} - -export default AuthorsSerializer; diff --git a/test/test-app/app/serializers/categorizations.js b/test/test-app/app/serializers/categorizations.js new file mode 100644 index 00000000..f7648306 --- /dev/null +++ b/test/test-app/app/serializers/categorizations.js @@ -0,0 +1,10 @@ +import { Serializer } from 'LUX_LOCAL'; + +class CategorizationsSerializer extends Serializer { + hasOne = [ + 'tag', + 'post' + ]; +} + +export default CategorizationsSerializer; diff --git a/test/test-app/app/serializers/comments.js b/test/test-app/app/serializers/comments.js new file mode 100644 index 00000000..9431bdab --- /dev/null +++ b/test/test-app/app/serializers/comments.js @@ -0,0 +1,21 @@ +import { Serializer } from 'LUX_LOCAL'; + +class CommentsSerializer extends Serializer { + attributes = [ + 'edited', + 'message', + 'createdAt', + 'updatedAt' + ]; + + hasOne = [ + 'post', + 'user' + ]; + + hasMany = [ + 'reactions' + ]; +} + +export default CommentsSerializer; diff --git a/test/test-app/app/serializers/friendships.js b/test/test-app/app/serializers/friendships.js new file mode 100644 index 00000000..67868e55 --- /dev/null +++ b/test/test-app/app/serializers/friendships.js @@ -0,0 +1,12 @@ +import { Serializer } from 'LUX_LOCAL'; + +class FriendshipsSerializer extends Serializer { + attributes = [ + 'followerId', + 'followeeId', + 'createdAt', + 'updatedAt' + ]; +} + +export default FriendshipsSerializer; diff --git a/test/test-app/app/serializers/notifications.js b/test/test-app/app/serializers/notifications.js new file mode 100644 index 00000000..d5eed9ea --- /dev/null +++ b/test/test-app/app/serializers/notifications.js @@ -0,0 +1,16 @@ +import { Serializer } from 'LUX_LOCAL'; + +class NotificationsSerializer extends Serializer { + attributes = [ + 'unread', + 'message', + 'createdAt', + 'updatedAt' + ]; + + hasOne = [ + 'recipient' + ]; +} + +export default NotificationsSerializer; diff --git a/test/test-app/app/serializers/posts.js b/test/test-app/app/serializers/posts.js index 1c22128e..15f47931 100644 --- a/test/test-app/app/serializers/posts.js +++ b/test/test-app/app/serializers/posts.js @@ -9,7 +9,13 @@ class PostsSerializer extends Serializer { ]; hasOne = [ - 'author' + 'user' + ]; + + hasMany = [ + 'comments', + 'reactions', + 'tags' ]; } diff --git a/test/test-app/app/serializers/reactions.js b/test/test-app/app/serializers/reactions.js new file mode 100644 index 00000000..efdda1d3 --- /dev/null +++ b/test/test-app/app/serializers/reactions.js @@ -0,0 +1,16 @@ +import { Serializer } from 'LUX_LOCAL'; + +class ReactionsSerializer extends Serializer { + attributes = [ + 'type', + 'createdAt' + ]; + + hasOne = [ + 'post', + 'user', + 'comment' + ]; +} + +export default ReactionsSerializer; diff --git a/test/test-app/app/serializers/tags.js b/test/test-app/app/serializers/tags.js index aeb2c32c..fd887d2e 100644 --- a/test/test-app/app/serializers/tags.js +++ b/test/test-app/app/serializers/tags.js @@ -4,6 +4,10 @@ class TagsSerializer extends Serializer { attributes = [ 'name' ]; + + hasMany = [ + 'posts' + ]; } export default TagsSerializer; diff --git a/test/test-app/app/serializers/users.js b/test/test-app/app/serializers/users.js new file mode 100644 index 00000000..8336e340 --- /dev/null +++ b/test/test-app/app/serializers/users.js @@ -0,0 +1,18 @@ +import { Serializer } from 'LUX_LOCAL'; + +class UsersSerializer extends Serializer { + attributes = [ + 'name', + 'email' + ]; + + hasMany = [ + 'posts', + 'comments', + 'followees', + 'followers', + 'reactions' + ]; +} + +export default UsersSerializer; diff --git a/test/test-app/app/utils/password.js b/test/test-app/app/utils/password.js new file mode 100644 index 00000000..2c1080bb --- /dev/null +++ b/test/test-app/app/utils/password.js @@ -0,0 +1,25 @@ +import bcrypt from 'bcryptjs'; + +export function hashPassword(password) { + return new Promise((resolve, reject) => { + bcrypt.hash(password, 10, (err, hash) => { + if (err) { + return reject(err); + } + + resolve(hash); + }); + }); +} + +export function comparePassword(password, hash) { + return new Promise((resolve, reject) => { + bcrypt.compare(password, hash, (err, result) => { + if (err) { + return reject(err); + } + + resolve(result); + }); + }); +} diff --git a/test/test-app/app/utils/track.js b/test/test-app/app/utils/track.js new file mode 100644 index 00000000..16f765c9 --- /dev/null +++ b/test/test-app/app/utils/track.js @@ -0,0 +1,17 @@ +import Action from '../models/action'; + +export default async function track(trackable) { + if (trackable) { + const { + id: trackableId, + constructor: { + name: trackableType + } + } = trackable; + + return await Action.create({ + trackableType, + trackableId + }); + } +} diff --git a/test/test-app/config/database.js b/test/test-app/config/database.js index f9b544ed..830d00fd 100644 --- a/test/test-app/config/database.js +++ b/test/test-app/config/database.js @@ -1,6 +1,6 @@ const { env: { - DATABASE_DRIVER, + DATABASE_DRIVER = 'sqlite3', DATABASE_USERNAME, DATABASE_PASSWORD, } diff --git a/test/test-app/db/migrate/2016050414243068-create-authors.js b/test/test-app/db/migrate/2016050414243068-create-authors.js deleted file mode 100644 index e87e15d2..00000000 --- a/test/test-app/db/migrate/2016050414243068-create-authors.js +++ /dev/null @@ -1,19 +0,0 @@ -export function up(schema) { - return schema.createTable('authors', table => { - table.increments('id'); - - table.string('name').defaultTo('New Author'); - - table.timestamps(); - - table.index([ - 'name', - 'created_at', - 'updated_at' - ]); - }); -} - -export function down(schema) { - return schema.dropTable('authors'); -} diff --git a/test/test-app/db/migrate/2016050414243335-create-posts.js b/test/test-app/db/migrate/2016050414243335-create-posts.js deleted file mode 100644 index 5d34092a..00000000 --- a/test/test-app/db/migrate/2016050414243335-create-posts.js +++ /dev/null @@ -1,24 +0,0 @@ -export function up(schema) { - return schema.createTable('posts', table => { - table.increments('id'); - - table.string('title').defaultTo('New Post'); - table.text('body'); - table.boolean('is_public').defaultTo(false); - table.integer('author_id'); - - table.timestamps(); - - table.index([ - 'title', - 'is_public', - 'author_id', - 'created_at', - 'updated_at' - ]); - }); -} - -export function down(schema) { - return schema.dropTable('posts'); -} diff --git a/test/test-app/db/migrate/2016051621371582-create-users.js b/test/test-app/db/migrate/2016051621371582-create-users.js new file mode 100644 index 00000000..eca7984a --- /dev/null +++ b/test/test-app/db/migrate/2016051621371582-create-users.js @@ -0,0 +1,24 @@ +export function up(schema) { + return schema.createTable('users', table => { + table.increments('id'); + + table.string('name') + .index() + .notNullable(); + + table.string('email') + .index() + .unique() + .notNullable(); + + table.string('password') + .notNullable(); + + table.timestamps(); + table.index(['created_at', 'updated_at']); + }); +} + +export function down(schema) { + return schema.dropTable('users'); +} diff --git a/test/test-app/db/migrate/2016051621393305-create-actions.js b/test/test-app/db/migrate/2016051621393305-create-actions.js new file mode 100644 index 00000000..313a6f81 --- /dev/null +++ b/test/test-app/db/migrate/2016051621393305-create-actions.js @@ -0,0 +1,20 @@ +export function up(schema) { + return schema.createTable('actions', table => { + table.increments('id'); + + table.integer('trackable_id') + .index() + .notNullable(); + + table.string('trackable_type') + .index() + .notNullable(); + + table.timestamps(); + table.index(['created_at', 'updated_at']); + }); +} + +export function down(schema) { + return schema.dropTable('actions'); +} diff --git a/test/test-app/db/migrate/2016051621405358-create-comments.js b/test/test-app/db/migrate/2016051621405358-create-comments.js new file mode 100644 index 00000000..0387c47e --- /dev/null +++ b/test/test-app/db/migrate/2016051621405358-create-comments.js @@ -0,0 +1,26 @@ +export function up(schema) { + return schema.createTable('comments', table => { + table.increments('id'); + + table.string('message') + .notNullable(); + + table.boolean('edited') + .index() + .notNullable() + .defaultTo(false); + + table.integer('user_id') + .index(); + + table.integer('post_id') + .index(); + + table.timestamps(); + table.index(['created_at', 'updated_at']); + }); +} + +export function down(schema) { + return schema.dropTable('comments'); +} diff --git a/test/test-app/db/migrate/2016051621421145-create-reactions.js b/test/test-app/db/migrate/2016051621421145-create-reactions.js new file mode 100644 index 00000000..c5cb377b --- /dev/null +++ b/test/test-app/db/migrate/2016051621421145-create-reactions.js @@ -0,0 +1,29 @@ +export function up(schema) { + return schema.createTable('reactions', table => { + table.increments('id'); + + table.enum('type', [ + ':+1:', + ':heart:', + ':confetti_ball:', + ':laughing:', + ':disappointed:' + ]).index().notNullable(); + + table.integer('user_id') + .index(); + + table.integer('post_id') + .index(); + + table.integer('comment_id') + .index(); + + table.timestamps(); + table.index(['created_at', 'updated_at']); + }); +} + +export function down(schema) { + return schema.dropTable('reactions'); +} diff --git a/test/test-app/db/migrate/2016051621434009-create-posts.js b/test/test-app/db/migrate/2016051621434009-create-posts.js new file mode 100644 index 00000000..87733725 --- /dev/null +++ b/test/test-app/db/migrate/2016051621434009-create-posts.js @@ -0,0 +1,27 @@ +export function up(schema) { + return schema.createTable('posts', table => { + table.increments('id'); + + table.text('body'); + + table.string('title') + .index() + .notNullable() + .defaultTo('New Post'); + + table.boolean('is_public') + .index() + .defaultTo(false) + .notNullable(); + + table.integer('user_id') + .index(); + + table.timestamps(); + table.index(['created_at', 'updated_at']); + }); +} + +export function down(schema) { + return schema.dropTable('posts'); +} diff --git a/test/test-app/db/migrate/2016051621435837-create-friendships.js b/test/test-app/db/migrate/2016051621435837-create-friendships.js new file mode 100644 index 00000000..3e7adfda --- /dev/null +++ b/test/test-app/db/migrate/2016051621435837-create-friendships.js @@ -0,0 +1,18 @@ +export function up(schema) { + return schema.createTable('friendships', table => { + table.increments('id'); + + table.integer('followee_id') + .index(); + + table.integer('follower_id') + .index(); + + table.timestamps(); + table.index(['created_at', 'updated_at']); + }); +} + +export function down(schema) { + return schema.dropTable('friendships'); +} diff --git a/test/test-app/db/migrate/2016051621460053-create-notifications.js b/test/test-app/db/migrate/2016051621460053-create-notifications.js new file mode 100644 index 00000000..ac82214e --- /dev/null +++ b/test/test-app/db/migrate/2016051621460053-create-notifications.js @@ -0,0 +1,23 @@ +export function up(schema) { + return schema.createTable('notifications', table => { + table.increments('id'); + + table.string('message') + .notNullable(); + + table.boolean('unread') + .index() + .defaultTo(true) + .notNullable(); + + table.integer('recipient_id') + .index(); + + table.timestamps(); + table.index(['created_at', 'updated_at']); + }); +} + +export function down(schema) { + return schema.dropTable('notifications'); +} diff --git a/test/test-app/db/migrate/2016052023055747-create-tags.js b/test/test-app/db/migrate/2016061214092135-create-tags.js similarity index 57% rename from test/test-app/db/migrate/2016052023055747-create-tags.js rename to test/test-app/db/migrate/2016061214092135-create-tags.js index 775a6a86..3b019168 100644 --- a/test/test-app/db/migrate/2016052023055747-create-tags.js +++ b/test/test-app/db/migrate/2016061214092135-create-tags.js @@ -1,16 +1,13 @@ export function up(schema) { return schema.createTable('tags', table => { table.increments('id'); - table.string('name'); - table.integer('post_id'); - table.timestamps(); - table.index([ - 'id', - 'post_id', - 'created_at', - 'updated_at' - ]); + table.string('name') + .index() + .notNullable(); + + table.timestamps(); + table.index(['created_at', 'updated_at']); }); } diff --git a/test/test-app/db/migrate/2016061214112168-create-categorizations.js b/test/test-app/db/migrate/2016061214112168-create-categorizations.js new file mode 100644 index 00000000..0035b451 --- /dev/null +++ b/test/test-app/db/migrate/2016061214112168-create-categorizations.js @@ -0,0 +1,18 @@ +export function up(schema) { + return schema.createTable('categorizations', table => { + table.increments('id'); + + table.integer('post_id') + .index(); + + table.integer('tag_id') + .index(); + + table.timestamps(); + table.index(['created_at', 'updated_at']); + }); +} + +export function down(schema) { + return schema.dropTable('categorizations'); +} diff --git a/test/test-app/db/seed.js b/test/test-app/db/seed.js index c812ca8e..231ea2ce 100644 --- a/test/test-app/db/seed.js +++ b/test/test-app/db/seed.js @@ -1,25 +1,85 @@ -import Author from '../app/models/author'; +import faker from 'faker'; + +import Categorization from '../app/models/categorization'; +import Comment from '../app/models/comment'; import Post from '../app/models/post'; +import Reaction from '../app/models/reaction'; +import Tag from '../app/models/tag'; +import User from '../app/models/user'; +import Friendship from '../app/models/friendship'; import range from '../app/utils/range'; +const { + name, + lorem, + random, + internet, + + helpers: { + randomize + } +} = faker; + export default async function seed() { await Promise.all( - [1, 2].map(n => { - return Author.create({ - name: `New Author ${n}` - }); - }) + Array.from(range(1, 100)).map(() => User.create({ + name: `${name.firstName()} ${name.lastName()}`, + email: internet.email(), + password: internet.password(randomize([...range(8, 127)])) + })) + ); + + await Promise.all( + Array.from(range(1, 100)).map(() => Friendship.create({ + followerId: randomize([...range(1, 100)]), + followeeId: randomize([...range(1, 100)]) + })) ); await Promise.all( - [...range(1, 50)].map(n => { - return Post.create({ - body: 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed...', - title: `New Post ${n}`, - isPublic: true, - authorId: n < 25 ? 1 : 2 - }); - }) + Array.from(range(1, 100)).map(() => Post.create({ + body: lorem.paragraphs(), + title: lorem.sentence(), + userId: randomize([...range(1, 100)]), + isPublic: random.boolean() + })) + ); + + await Promise.all( + Array.from(range(1, 100)).map(() => Tag.create({ + name: lorem.word() + })) + ); + + await Promise.all( + Array.from(range(1, 100)).map(() => Categorization.create({ + postId: randomize([...range(1, 100)]), + tagId: randomize([...range(1, 100)]) + })) + ); + + await Promise.all( + Array.from(range(1, 100)).map(() => Comment.create({ + message: lorem.sentence(), + edited: random.boolean(), + userId: randomize([...range(1, 100)]), + postId: randomize([...range(1, 100)]) + })) + ); + + await Promise.all( + Array.from(range(1, 100)).map(() => Reaction.create({ + [`${randomize(['comment', 'post'])}Id`]: randomize([...range(1, 100)]), + userId: randomize([...range(1, 100)]), + + type: randomize([ + ':+1:', + ':heart:', + ':confetti_ball:', + ':laughing:', + ':disappointed:' + ]) + })) ); -} +}; diff --git a/test/test-app/package.json b/test/test-app/package.json index 2b5d179e..8e0e0a05 100644 --- a/test/test-app/package.json +++ b/test/test-app/package.json @@ -12,10 +12,14 @@ "dependencies": { "babel-core": "6.14.0", "babel-preset-lux": "1.2.0", + "bcryptjs": "2.3.0", "knex": "0.12.1", "mssql": "3.3.0", "mysql2": "1.0.0", "pg": "6.1.0", "sqlite3": "3.1.4" + }, + "devDependencies": { + "faker": "3.1.0" } } diff --git a/test/unit/packages/fs.js b/test/unit/packages/fs.js deleted file mode 100644 index cecfced4..00000000 --- a/test/unit/packages/fs.js +++ /dev/null @@ -1,34 +0,0 @@ -import { expect } from 'chai'; - -import { isJSFile } from '../../../src/packages/fs'; - -describe('Unit: class fs ', () => { - describe('Unit: util isJSFile', () => { - const subject = { - a: 'author.js', - b: '.gitkeep', - c: 'author.js~' - }; - - it('is a JavaScript file', () => { - const result = isJSFile(subject.a); - - expect(result).to.be.a('boolean'); - expect(result).to.equal(true); - }); - - it('filter out hidden files', () => { - const result = isJSFile(subject.b); - - expect(result).to.be.a('boolean'); - expect(result).to.equal(false); - }); - - it('filter out non JavaScript files', () => { - const result = isJSFile(subject.c); - - expect(result).to.be.a('boolean'); - expect(result).to.equal(false); - }); - }); -}); diff --git a/test/unit/packages/serializer.js b/test/unit/packages/serializer.js deleted file mode 100644 index e0e78467..00000000 --- a/test/unit/packages/serializer.js +++ /dev/null @@ -1,26 +0,0 @@ -import { expect } from 'chai'; - -import fetch from '../../utils/fetch'; - -const host = 'http://localhost:4000'; - -describe('Unit: class Serializer ', () => { - describe('Regression: #relationshipsFor() (https://github.com/postlight/lux/issues/59)', () => { - it('can serialize hasMany relationships', async () => { - const { - data: [ - { - relationships: { - posts: { - data: posts - } - } - } - ] - } = await (await fetch(`${host}/authors`)).json(); - - expect(posts).to.be.an.instanceOf(Array); - expect(posts).to.have.length.above(0); - }); - }); -}); diff --git a/test/unit/packages/server.js b/test/unit/packages/server.js deleted file mode 100644 index 1ac8939d..00000000 --- a/test/unit/packages/server.js +++ /dev/null @@ -1,54 +0,0 @@ -import { expect } from 'chai'; - -import fetch from '../../utils/fetch'; - -const host = 'http://localhost:4000'; - -describe('Unit: class Server ', () => { - describe('Regression: util formatParams (https://github.com/postlight/lux/issues/42)', () => { - let createdId; - - it('parses strings containing commas as a String for POST requests', async () => { - const { data: { id, attributes } } = await ( - await fetch(`${host}/posts`, { - method: 'POST', - body: JSON.stringify({ - data: { - type: 'posts', - attributes: { - title: 'Hello, world!' - } - } - }) - }) - ).json(); - - createdId = id; - - expect(attributes).to.have.property('title', 'Hello, world!'); - }); - - it('parses strings containing commas as a String for PATCH requests', async () => { - const { data: { attributes } } = await ( - await fetch(`${host}/posts/${createdId}`, { - method: 'PATCH', - body: JSON.stringify({ - data: { - id: createdId, - type: 'posts', - attributes: { - title: 'It, works!' - } - } - }) - }) - ).json(); - - expect(attributes).to.have.property('title', 'It, works!'); - }); - - after(async () => { - await fetch(`${host}/posts/${createdId}`, { method: 'DELETE' }); - }); - }); -}); diff --git a/test/unit/utils/camelize-keys.js b/test/unit/utils/camelize-keys.js deleted file mode 100644 index 6b5ce060..00000000 --- a/test/unit/utils/camelize-keys.js +++ /dev/null @@ -1,50 +0,0 @@ -import { expect } from 'chai'; - -import { camelizeKeys } from '../../../src/utils/transform-keys'; - -describe('Unit: util camelizeKeys', () => { - const subject = { - 'value-a': 'a', - 'value_b': 'b', - 'value-c': { - 'value-c_d': 'cd' - } - }; - - it('shallow converts an object\'s keys to lower camel case', () => { - const result = camelizeKeys(subject); - - expect(result).to.deep.equal({ - valueA: 'a', - valueB: 'b', - valueC: { - 'value-c_d': 'cd' - } - }); - }); - - it('deep converts an object\'s keys to lower camel case', () => { - const result = camelizeKeys(subject, true); - - expect(result).to.deep.equal({ - valueA: 'a', - valueB: 'b', - valueC: { - valueCD: 'cd' - } - }); - }); - - it('does not mutate the source object', () => { - camelizeKeys(subject); - camelizeKeys(subject, true); - - expect(subject).to.deep.equal({ - 'value-a': 'a', - 'value_b': 'b', - 'value-c': { - 'value-c_d': 'cd' - } - }); - }); -}); diff --git a/test/unit/utils/create-query-string.js b/test/unit/utils/create-query-string.js deleted file mode 100644 index c0fcf179..00000000 --- a/test/unit/utils/create-query-string.js +++ /dev/null @@ -1,40 +0,0 @@ -import { expect } from 'chai'; - -import { line } from '../../../src/packages/logger'; - -import createQueryString from '../../../src/utils/create-query-string'; - -describe('Unit: util createQueryString', () => { - const expected = line` - sort=-created-at& - ids=1,2,3& - page%5Bsize%5D=25& - page%5Bnumber%5D=1& - filter%5Bname%5D=test - `.replace(/\s/g, ''); - - const subject = { - sort: '-created-at', - - ids: [ - 1, - 2, - 3 - ], - - page: { - size: 25, - number: 1 - }, - - filter: { - name: 'test' - } - }; - - it('creates a valid query string', () => { - const result = createQueryString(subject); - - expect(result).to.equal(expected); - }); -}); diff --git a/test/unit/utils/omit.js b/test/unit/utils/omit.js deleted file mode 100644 index ff661a5a..00000000 --- a/test/unit/utils/omit.js +++ /dev/null @@ -1,29 +0,0 @@ -import { expect } from 'chai'; - -import omit from '../../../src/utils/omit'; - -describe('Unit: util omit', () => { - const subject = { - a: 1, - b: 2, - c: 3 - }; - - it('filters out keys that are passed as arguments', () => { - const result = omit(subject, 'b', 'c'); - - expect(result).to.deep.equal({ - a: 1 - }); - }); - - it('does not mutate the source object', () => { - omit(subject, 'b', 'c'); - - expect(subject).to.deep.equal({ - a: 1, - b: 2, - c: 3 - }); - }); -}); diff --git a/test/unit/utils/pick.js b/test/unit/utils/pick.js deleted file mode 100644 index e5640f5a..00000000 --- a/test/unit/utils/pick.js +++ /dev/null @@ -1,30 +0,0 @@ -import { expect } from 'chai'; - -import pick from '../../../src/utils/pick'; - -describe('Unit: util pick', () => { - const subject = { - a: 1, - b: 2, - c: 3 - }; - - it('filters out keys that are not passed as arguments', () => { - const result = pick(subject, 'a', 'c'); - - expect(result).to.deep.equal({ - a: 1, - c: 3 - }); - }); - - it('does not mutate the source object', () => { - pick(subject, 'a', 'c'); - - expect(subject).to.deep.equal({ - a: 1, - b: 2, - c: 3 - }); - }); -}); diff --git a/test/utils/fetch.js b/test/utils/fetch.js deleted file mode 100644 index d97a1d59..00000000 --- a/test/utils/fetch.js +++ /dev/null @@ -1,11 +0,0 @@ -import fetch from 'isomorphic-fetch'; - -import { MIME_TYPE } from '../../src/packages/jsonapi'; - -export default (url, opts = {}) => fetch(url, { - ...opts, - headers: new Headers({ - 'Accept': MIME_TYPE, - 'Content-Type': MIME_TYPE - }) -}); diff --git a/test/utils/get-test-app.js b/test/utils/get-test-app.js new file mode 100644 index 00000000..b40412f5 --- /dev/null +++ b/test/utils/get-test-app.js @@ -0,0 +1,38 @@ +// @flow +import { join as joinPath } from 'path'; + +import type Application from '../../src/packages/application'; + +let instance; + +async function setupTestApp(): Promise { + const port = 4000; + const path = joinPath(__dirname, '..', 'test-app'); + + const { + config, + database, + Application: TestApp + }: { + config: Object; + database: Object; + Application: Class; + } = Reflect.apply(require, null, [ + joinPath(path, 'dist', 'bundle') + ]); + + return await new TestApp({ + ...config, + database, + path, + port + }); +} + +export async function getTestApp() { + if (!instance) { + instance = await setupTestApp(); + } + + return instance; +}