From 79f75b83984cd833b07c9326ad54b84a2eece8b2 Mon Sep 17 00:00:00 2001 From: Jon Winton Date: Thu, 20 Sep 2018 17:13:22 -0400 Subject: [PATCH] V7 (#581) * stashing small update * stashing changes; * stashing changes before beginning testing refactor * testing for non-api tests * fixing api tests * updating db tests * 100% * eslint * moving packages over to dev dependencies * more updates * lots of changes * metadata for the layouts * forgot file * more layouts/meta work * getting things in order * New DB API: Storage (#554) * stashing small update * stashing changes; * stashing changes before beginning testing refactor * testing for non-api tests * fixing api tests * updating db tests * 100% * eslint * moving packages over to dev dependencies * Meta Patch Requests (#569) * adding patch support * code comments * Publish data meta (#571) * adding patch support * code comments * bringing in meta updating * cleanup * cleaning up some publish/unpublish logic and keeping page history * passing bus publish method to plugins * using meta object and raw query for db * small pruning and comments * using amphora-fs for file service * updating for amphora-search * lists api * removing cache control * moving plugins * some testing work * components & composer services * stashing tests * more tests * middle of tests * stashing test changes * initial fixes done, moving to api tests * most testing work done * mostly done * fixing model call * making a doc of changing things * linting and test changes * writing plugin docs * writing more docs * adding to table of contents * pass site to attachRoutes * do not need that package * fixing issue * newline * no actual changes * Validate site routes (#598) * no actual changes * attach amphora api routes to the router first to avoid misleading errors caused by less specific site-level routes * set reservedRoutes in a function + pr feedback * making long stacktraces an env var * stashing changes * fixing up some tests * actually fixing tests * V7 meta changes (#605) * no actual changes * some fixes discovered when working with kiln * unwrap list routes in amphora-storage-postgres * normalizing fields between page and layout metadata and removing unused fields * use iso string timestamps * 7.0.0-0 * Redirect From Published Meta (#607) * adding in redirect and fixing eslint comment * moving to responses * name change * look for custom url and dynamic prop before site pub chain * removing comment * package-lock --- .eslintrc | 2 +- docs/SUMMARY.md | 2 +- docs/advanced/storage.md | 152 ++++++ docs/basics/plugins.md | 42 ++ docs/basics/routes.md | 1 - docs/plugins/README.md | 25 - docs/plugins/hooks.md | 100 ---- docs/plugins/plugin-vs-renderer.md | 22 - docs/plugins/writing-a-plugin.md | 35 -- docs/startup/instantiation.md | 3 - index.js | 1 - lib/auth.js | 6 +- lib/auth.test.js | 18 +- lib/bootstrap.js | 3 +- lib/bootstrap.test.js | 14 +- lib/plugins/index.js | 46 -- lib/plugins/index.test.js | 70 --- lib/plugins/readme.md | 86 ---- lib/render.js | 20 +- lib/render.test.js | 49 +- lib/responses.js | 82 ++-- lib/responses.test.js | 44 +- lib/routes.js | 64 +-- lib/routes.test.js | 9 +- lib/routes/_components.js | 55 +-- lib/routes/_layouts.js | 337 ++++++++++++++ lib/routes/_lists.js | 6 - lib/routes/_pages.js | 63 ++- lib/routes/_schedule.js | 66 --- lib/routes/_uris.js | 9 +- lib/routes/_users.js | 29 +- lib/services/attachRoutes.js | 52 ++- lib/services/attachRoutes.test.js | 44 +- lib/services/components.js | 129 ++---- lib/services/components.test.js | 272 +++-------- lib/services/composer.js | 16 +- lib/services/composer.test.js | 11 + lib/services/db-operations.js | 5 + lib/services/db-operations.test.js | 7 +- lib/services/db.js | 204 +------- lib/services/db.test.js | 231 ++------- lib/services/layouts.js | 113 +++++ lib/services/layouts.test.js | 120 +++++ lib/services/metadata.js | 249 ++++++++++ lib/services/metadata.test.js | 158 +++++++ lib/services/models.js | 6 +- lib/services/models.test.js | 28 +- lib/services/notifications.js | 2 +- lib/services/pages.js | 227 +++++---- lib/services/pages.test.js | 408 ++++++++-------- lib/services/plugins.js | 75 +++ lib/services/plugins.test.js | 65 +++ lib/services/publish.js | 126 +++-- lib/services/publish.test.js | 102 ++-- lib/services/readme.md | 7 - lib/services/references.js | 6 +- lib/services/schedule.js | 218 --------- lib/services/schedule.test.js | 223 --------- lib/services/sites.js | 2 +- lib/services/upgrade.js | 33 +- lib/services/upgrade.test.js | 18 +- lib/services/uris.js | 40 +- lib/services/uris.test.js | 36 +- lib/services/users.js | 12 +- lib/services/users.test.js | 7 +- lib/setup.js | 30 +- lib/setup.test.js | 19 +- lib/utils/schema.js | 8 +- package-lock.json | 719 ++++++++++++++--------------- package.json | 8 +- test/api/_components/get.js | 1 - test/api/_layouts/delete.js | 134 ++++++ test/api/_layouts/get.js | 162 +++++++ test/api/_layouts/patch.js | 33 ++ test/api/_layouts/post.js | 109 +++++ test/api/_layouts/put.js | 222 +++++++++ test/api/_pages/get.js | 21 + test/api/_pages/patch.js | 33 ++ test/api/_pages/post.js | 5 +- test/api/_pages/put.js | 47 +- test/api/_schedule/delete.js | 58 --- test/api/_schedule/get.js | 55 --- test/api/_schedule/post.js | 55 --- test/api/_schedule/put.js | 50 -- test/api/render/index.test.js | 24 +- test/fixtures/api-accepts.js | 82 +++- test/fixtures/config/bad.yml | 2 + test/fixtures/mocks/storage.js | 164 +++++++ test/index.js | 18 +- v7-changes.md | 14 + 90 files changed, 3732 insertions(+), 3054 deletions(-) create mode 100644 docs/advanced/storage.md create mode 100644 docs/basics/plugins.md delete mode 100644 docs/plugins/README.md delete mode 100644 docs/plugins/hooks.md delete mode 100644 docs/plugins/plugin-vs-renderer.md delete mode 100644 docs/plugins/writing-a-plugin.md delete mode 100644 lib/plugins/index.js delete mode 100644 lib/plugins/index.test.js delete mode 100644 lib/plugins/readme.md create mode 100644 lib/routes/_layouts.js delete mode 100644 lib/routes/_schedule.js create mode 100644 lib/services/layouts.js create mode 100644 lib/services/layouts.test.js create mode 100644 lib/services/metadata.js create mode 100644 lib/services/metadata.test.js create mode 100644 lib/services/plugins.js create mode 100644 lib/services/plugins.test.js delete mode 100644 lib/services/readme.md delete mode 100644 lib/services/schedule.js delete mode 100644 lib/services/schedule.test.js create mode 100644 test/api/_layouts/delete.js create mode 100644 test/api/_layouts/get.js create mode 100644 test/api/_layouts/patch.js create mode 100644 test/api/_layouts/post.js create mode 100644 test/api/_layouts/put.js create mode 100644 test/api/_pages/patch.js delete mode 100644 test/api/_schedule/delete.js delete mode 100644 test/api/_schedule/get.js delete mode 100644 test/api/_schedule/post.js delete mode 100644 test/api/_schedule/put.js create mode 100644 test/fixtures/config/bad.yml create mode 100644 test/fixtures/mocks/storage.js create mode 100644 v7-changes.md diff --git a/.eslintrc b/.eslintrc index bfdf823b..fefcb720 100644 --- a/.eslintrc +++ b/.eslintrc @@ -22,7 +22,7 @@ // best practices "complexity": [2, 8], "default-case": 2, - "guard-for-in": 2, + "guard-for-in": 0, "no-alert": 1, "no-floating-decimal": 1, "no-self-compare": 2, diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index c81d9c7f..954c114a 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -11,6 +11,7 @@ * [Building Custom Renderers](basics/renderers/custom-renderers.md) * [Event Bus](basics/event-bus.md) * [Advanced](advanced/README.md) + * [Storage API](advanced/storage.md) * [Data Versioning \(Upgrades\)](advanced/upgrade.md) * [Renderer Models](advanced/renderer-models.md) * [Plugins](plugins/README.md) @@ -18,4 +19,3 @@ * [Writing A Plugin](plugins/writing-a-plugin.md) * [Plugin vs. Renderer](plugins/plugin-vs-renderer.md) * [Glossary](glossary.md) - diff --git a/docs/advanced/storage.md b/docs/advanced/storage.md new file mode 100644 index 00000000..b1273edf --- /dev/null +++ b/docs/advanced/storage.md @@ -0,0 +1,152 @@ +# Storage + +The Storage API for Amphora is meant to provide a bridge between Amphora's composition layer and how data for sites in Clay is persisted. At its core, Amphora maintains six top-level data structures (Components, Layouts, Pages, Users, Uris, Lists) and it's the responsibility of the storage module to store and compose the data. + +Currently it is possible to write any storage midule that best fits the needs of your Clay instance, but the platform officially supports a Postgres storage module that [can be found here](https://github.com/clay/amphora-storage-postgres). + +For more information in writing your own storage module you can refer to this module as an example. + +--- + +## Setup + +The function in which the storage module should connect to a database as well as perform any other instantiation operations, such as setting up required tables/collections. + +**Returns:** `Promise` + +--- + +## Put + +This function is a simple write to the DB for any of the Clay data structures. It should be able to handle writing one individual component/layout/page/list/uri/user. The return value should + +**Arguments:** `key` (String), `value` (Object) + +**Returns:** `Promise` + +--- + +## Get + +This function should return one individual instance of a any of the data types. + +**Arguments:** `key` (String) + +**Returns:** `Promise` + +--- + +## Del + +This function should delete one individual instance of any of the data types. The value of the item that was just deleted should be returned. + +**Arguments:** `key` (String) + +**Returns:** `Promise` + +--- + +## Batch + +A function which accepts an array of objects (operations), with each object representing one "save" action for any of the supported data structures. An operation is an object with the following structure: + +- `key` (String): the id of the individual component/layout/page/uri/list/user instance +- `value` (Object): the value to be saved for the key + +```javascript +// Example operations array +[{ + key: 'domain.com/_components/foo/instances/bar', + value: { + foobar: 'baz' + } +}, { + key: 'domain.com/_pages/foobarbaz', + value: { + layout: 'domain.com/_layouts/layout/instances/default', + main: [ + 'domain.com/_components/foo/instances/bar' + ] + } +}] +``` + +**Arguments:** `ops` (Array) + +**Returns:** `Promise` + +--- + +## Get Meta + +Retrieves the metadata for a page/layout. The function will always be called with the raw page/layout uri, not a uri ending in `/meta`. + +For example, Amphora will respond to a request for `domain.com/_pages/foo/meta` by making the following invoking the `getMeta` function of the storage module with a key of `domain.com/_pages/foo` and then returning the data to client. If no data is returned, Amphora will return an empty object. + +**Arguments:** `key` (String) + +**Returns:** `Promise` + +--- + +## Put Meta + +Saves the metadata for a page/layout. The function will always be called with the raw page/layout uri, not a uri ending in `/meta`. + +**Arguments:** `key` (String), `value` (Object) + +**Returns:** `Promise` + +--- + +## Patch Meta + +Updates properties on the metadata object with the values passed into the request. This method should _never_ update the entire metadata object with what's passed to the function or metadata will be destroyed when users edit data. + +If the storage solution does not support partial updates to the data then the function will need to request the full object from the database, merge the old data with the new data and then save the merged object. + +**Arguments:** `key` (String), `value` (Object) + +**Returns:** `Promise` + +--- + +## Create Read Stream + +Amphora responds to certain requests with a Stream of data, such as a request to `domain.com/_components/foo/instances`. In this case, Amphora will read all the instances of the `foo` component from the database and send back an array of component uris. To handle this in the most memory efficient way, Amphora processes the data from the DB as a Stream, and the `createReadStream` function should return a Read Stream for Amphora to act on. + +The function will receive an object of options with three properties: + +- `prefix` (String): the string which should prefix all of the keys that Amphora needs to display data for +- `keys` (Boolean): if `true`, the stream should return the uri of all data matcing the prefix +- `values` (Boolean): if `true`, the stream should return the values of all uris matcing the prefix + +Using the example from earlier, if a request comes in for `domain.com/_components/foo/instances`, Amphora will pass an `options` object to the `createReadStream` function like so: + +```javascript +// Example `options` object for the createReadStream function +{ + prefix: 'domain.com/_components/foo/instances', + keys: true, + values: false +} +``` + +Amphora would expect a Read Stream (in object mode) where each item in the stream is an object with the following signature: + +- `key` (String)[Optional]: the uri of the component/layout/page/uri/user/list +- `value` (Object)[Optional]: the object/string value of the uri + +Amphora will then take this data and make sure it's a properly formatted response to the client that initiated the request. + +--- + +## Raw + +The `raw` function is the most ambiguous function of the API, but is the crux of plugins. To ensure that plugins can add in their own data to the database that your Clay instance is using, the db client is passed to each plugin included in your instance. + +This puts a lot of responsibility on plugins to manage their own data properly, but allows developers to write their own plugins to accomplish anything with Clay. + +This function should simply be a passthrough to the client that connects to the data store being used and should return a Promise for any executed action. + +**Returns:** `Promise` \ No newline at end of file diff --git a/docs/basics/plugins.md b/docs/basics/plugins.md new file mode 100644 index 00000000..01b9dfc4 --- /dev/null +++ b/docs/basics/plugins.md @@ -0,0 +1,42 @@ +# Plugins + +Plugins allow for you to extend the functionality of your site by allowing you to attach routes to each site's router in your instance. While this may not seem any different than [defining routes for your site](/docs/basics/routes.md), the basic site router will assign routes that only respond to `GET` requests and will run through Amphora's composition/rendering functionality. Plugins allow you to assign routes that respond to any [Express supported request method](https://expressjs.com/en/4x/api.html#app.METHOD) with your own custom handlers. + +## Anatomy of a Plugin + +A plugin should be a function and will receive the following arguments: + +- `router`: the router for the site. Attach listeners and handlers as you would to any Express router. +- `db`: an instance of Amphora's internal database connector. +- `bus`: a passthrough of Amphora's internal event bus publish method so that a plugin can publish to the event bus on its own. +- `sites`: the internal `sites` service, used for discovering which site in your instance a uri belongs to. + +An example plugin might look like the following: + +```javascript +module.exports = (router, db, bus, sites) => { + // Attach a route that responds to PUT requests + router.put('/_coolroute', (req, res) => { + return db.put(req.uri, req.body) + .then(() => { + // Pub to the event bus that the route was called + bus('coolRouteCalled', { body: req.body }); + // Respond to the request + res.json({ status: 200 }) + }); + }); + + // Note: we're not explicity returning anything, but if we were + // performing some async action we could return a Promise +} +``` + +You aren't required to explicitly `return` any value from your plugin, but you can return a `Promise` if needed. + +## Lifecycle of Plugin + +A plugin is called... + +- once for every site in your Clay instance +- _after_ the storage module is instantiated +- _before_ bootstrapping happens diff --git a/docs/basics/routes.md b/docs/basics/routes.md index dc7e9c72..504c45af 100644 --- a/docs/basics/routes.md +++ b/docs/basics/routes.md @@ -48,4 +48,3 @@ module.exports.routes = [ ``` By adding this path object into your `routes` object you'll be able to create one page to handle all the requests to the `/archive/*` route. **Make sure your** [**dynamic page is published**](publishing.md#dynamic-pages--publishing) **or else this won't work!** - diff --git a/docs/plugins/README.md b/docs/plugins/README.md deleted file mode 100644 index db0273ca..00000000 --- a/docs/plugins/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Plugins - -{% hint style="danger" %} -Plugin functionality will change in Amphora v7.0.0, with many of the methods being deprecated in favor of using the [Event Bus](../basics/event-bus.md). Please upgrade to Amphora v6.6.0 as soon as possible and transition to using Event Bus topics. -{% endhint %} - -Plugins allow for you to extend functionality in Amphora by tapping into lifecycle hooks in Amphora to perform secondary actions on the server. - -**Important:** none of the plugin hooks allow you to manipulate the data that Amphora is processing, they only provide awareness of what has already been processed. - -## List of Hooks - -* `routes` -* `save` -* `delete` -* `publish` -* `publishPage` -* `createPage` -* `schedulePage` -* `unschedulePage` -* `unpublish` -* `unpublishPage` - -For more information about the arguments they receive, [see the hooks page](hooks.md). For an example of how to make a plugin, see the [Writing A Plugin](writing-a-plugin.md) page. - diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md deleted file mode 100644 index d2c930c5..00000000 --- a/docs/plugins/hooks.md +++ /dev/null @@ -1,100 +0,0 @@ -# Hooks - -{% hint style="danger" %} -Plugin functionality will change in Amphora v7.0.0, with many of the methods being deprecated in favor of using the [Event Bus](../basics/event-bus.md). Please upgrade to Amphora v6.6.0 as soon as possible and transition to using Event Bus topics. -{% endhint %} - -Below are details about the plugin hooks in Amphora. Plugin hooks are fired on each plugin supplied to Amphora at instantiation time. A plugin should expose a property whose name corresponds to one of the hooks below and is a function that expects the arguments detailed below. - -## Routes \(`routes`\) - -A hook that gets called with the argument of the [Express Router](https://expressjs.com/en/4x/api.html#express.router) for each site in a Clay instance. This hook allows a plugin to attach a route to a site that can then be called from a client. - -**Arguments:** - -* `router`. An instance of the [Express Router](https://expressjs.com/en/4x/api.html#express.router) for a site - -## Save \(`save`\) - -A hook that is called with an Array of DB operations that were just processed from Clay data structure\(s\) being saved. - -**Arguments:** - -* `ops`: an array of DB operations - -## Delete \(`delete`\) - -a hook that is called with an Array of DB operations that were just processed from a Clay data structure being deleted. - -**Arguments:** - -* `ops`: an array of DB operations - -## Publish \(`publish`\) - -A hook that is called whenever a page is published whose argument is an object with two properties contain all the data for the published page. - -**Arguments:** - -* `uri`: the uri of the page being published -* `ops`: an array of DB operations that were just processed for all components on the page, including the page's own data - -## Publish Page \(`publishPage`\) - -Similar to the `publish` hook with slightly different properties - -**Arguments:** - -* `uri`: the same as `uri` for the `publish` hook -* `data`: the page's data object -* `user`: the data for the user who triggered the action - -## Create Page \(`createPage`\) - -A hook fired when a page is created - -**Arguments:** - -* `uri`: the new page uri -* `data`: the data object of the new page -* `user`: the user who created the new page - -## Schedule Page \(`schedulePage`\) - -A hook fired when a page is scheduled - -**Arguments:** - -* `uri`: the uri for the page -* `data`: an object who contains data about when the page is scheduled for and which page it is -* `user`: the user who scheduled the page - -## Unschedule Page - -A hook fired when a page is unscheduled - -**Arguments:** - -* `uri`: the uri for the page -* `data`: an object who contains data about the page that was unscheduled as well as when the original scheduled time was -* `user`: the user who unscheduled the page - -## Unpublish \(`unpublish`\) - -A hook that's fired when a page is unpublished - -**Arguments:** - -* `url`: the publicly accessible url the page existed at -* `uri`: the page uri - -## Unpublish Page \(`unpublishPage`\) - -A hook fired when a page is unpublished that contains the `user` object as well - -**Arguments:** - -* `url`: the publicly accessible url the page existed at -* `uri`: the page uri -* `user`: the user who unpublished the page - diff --git a/docs/plugins/plugin-vs-renderer.md b/docs/plugins/plugin-vs-renderer.md deleted file mode 100644 index 92afd1f5..00000000 --- a/docs/plugins/plugin-vs-renderer.md +++ /dev/null @@ -1,22 +0,0 @@ -# Plugin vs. Renderer - -{% hint style="danger" %} -Plugin functionality will change in Amphora v7.0.0, with many of the methods being deprecated in favor of using the [Event Bus](../basics/event-bus.md). Please upgrade to Amphora v6.6.0 as soon as possible and transition to using Event Bus topics. -{% endhint %} - -While both plugins and renderers can be passed to Amphora, one key difference: - -_Plugins are observers that are invoked based on what has already been processed by Amphora, whereas renderers are part of the request/response lifecycle for displaying data._ - -## When would you write a renderer vs a plugin? - -**Plugin** - -* Sending data to a system outside of Clay based on some event \(publish, unpublish, etc.\) -* Collecting realtime data about what components are being created/published -* Programmatically creating other components/pages based on the content that has just been published - -**Renderer** - -* You want the data from Amphora displayed in a non-JSON format when a user requests a resource \(html, xml, rss\) - diff --git a/docs/plugins/writing-a-plugin.md b/docs/plugins/writing-a-plugin.md deleted file mode 100644 index 12ca2715..00000000 --- a/docs/plugins/writing-a-plugin.md +++ /dev/null @@ -1,35 +0,0 @@ -# Writing A Plugin - -{% hint style="danger" %} -Plugin functionality will change in Amphora v7.0.0, with many of the methods being deprecated in favor of using the [Event Bus](../basics/event-bus.md). Please upgrade to Amphora v6.6.0 as soon as possible and transition to using Event Bus topics. -{% endhint %} - -Writing a plugin is simple, all you need is to pass in an object to the [`plugins` instantiation argument](https://github.com/clay/amphora/tree/3a300d4ec7af113afd102b4506e7566eb617c9c8/docs/lifecycle/startup/instantiation.html#instantiation-arguments) who has properties which correspond to the hooks [listed on the hooks page](hooks.md). - -An example plugin is below. - -```javascript -// publish-plugin.js - -function onPagePublish(uri, data, user) { - console.log(`Page ${uri} was published by ${user.username}!`); -} - -module.exports.pagePublish = onPagePublish; -``` - -```javascript -// index.js -const amphora = require('amphora'), - myPlugin = require('publish-plugin'); - -// code before amphora instantiation... -amphora({ - plugins: [ - myPlugin - ] -}).then(...) -``` - -This example leaves out the proper Amphora instantiation in favor of showing how to write a very simple plugin and pass it to Amphora. The key takeaway is that plugins are objects whose top level properties correspond to the names of [Amphora hooks](hooks.md) with the value of those properties being function handlers. - diff --git a/docs/startup/instantiation.md b/docs/startup/instantiation.md index 3e76c2b3..345d7fdf 100644 --- a/docs/startup/instantiation.md +++ b/docs/startup/instantiation.md @@ -34,7 +34,4 @@ At instantiation time Amphora accepts a config object which contains properties * `renderers`: an Object that references modules that can be used to transform component data into different formats. For example, [Amphora HTML](https://github.com/clay/amphora-html) is a module that renders component data to HTML using Handlebars templates. Renderers abide by an API that Amphora sets forth. The `renderers` Object should contain properties whose names correspond to request extensions and whose properties are the handlers for those extensions. A `default` property is used to specify the renderer to be used when no extension is specified in a request. For more information, see the [Renderers](https://github.com/clay/amphora/tree/3a300d4ec7af113afd102b4506e7566eb617c9c8/docs/lifecycle/startup/authentication.md) documentation. * `plugins`: an Array of Objects that have handlers for different plugin hooks that Amphora exposes. Different hooks are exposed for the startup, request and publish life cycles. For more information see the [Plugins](https://github.com/clay/amphora/tree/3a300d4ec7af113afd102b4506e7566eb617c9c8/docs/lifecycle/startup/plugins.md) page. * `env`: an accommodation for renderers to expose environment variables used in `model.js` files on the client-side for [Kiln](https://github.com/clay/clay-kiln). These are only rendered in edit mode for a page. For a more thorough understanding of when/how these values are gathered and used, please see the [Component Models](https://github.com/clay/amphora/tree/3a300d4ec7af113afd102b4506e7566eb617c9c8/docs/lifecycle/startup/models.md) documentation. -* `cacheControl`: an Object with one argument: `staticMaxAge`, a Number which is passed through to the [`Express.static`](http://expressjs.com/en/4x/api.html#express.static) function as `maxAge`. Used for controlling `Cache-Control` headers for static assets. See the Express docs for more information about this argument. - * _Note: this argument will be added to soon, which is why it's an Object_ * `bootstrap`: a Boolean value which defaults to `true`. When set to `false` the internal [bootstrapping process](bootstrap.md#skipping-bootstrapping) will be skipped entirely. **It's advised not to set the value to** `false` **for production instances.** - diff --git a/index.js b/index.js index ff545eec..68b64d55 100644 --- a/index.js +++ b/index.js @@ -5,7 +5,6 @@ module.exports = require('./lib/setup'); module.exports.db = require('./lib/services/db'); module.exports.composer = require('./lib/services/composer'); module.exports.components = require('./lib/services/components'); -module.exports.schedule = require('./lib/services/schedule'); module.exports.pages = require('./lib/services/pages'); module.exports.sites = require('./lib/services/sites'); module.exports.references = require('./lib/services/references'); diff --git a/lib/auth.js b/lib/auth.js index 6cd7869b..8960a46f 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -2,7 +2,6 @@ const _ = require('lodash'), passport = require('passport'), users = require('./services/users'), - db = require('./services/db'), references = require('./services/references'), session = require('express-session'), flash = require('express-flash'), @@ -15,6 +14,7 @@ const _ = require('lodash'), ADMIN: 'admin', WRITE: 'write' }; +var db = require('./services/db'); /** * Check the auth level to see if a user @@ -130,7 +130,6 @@ function serializeUser(user, done) { function deserializeUser(uid, done) { return db.get(`/_users/${uid}`) - .then(JSON.parse) .then(function (user) { done(null, user); }) @@ -166,7 +165,6 @@ function verify(properties) { if (!req.user) { // first time logging in! update the user data return db.get(uid) - .then(JSON.parse) .then(function (data) { // only update the user data if the property doesn't exist (name might have been changed through the kiln UI) return _.defaults(data, { @@ -185,7 +183,6 @@ function verify(properties) { } else { // already authenticated. just grab the user data return db.get(uid) - .then(JSON.parse) .then((data) => done(null, data)) .catch(() => done(null, false, { message: 'User not found!' })); // no user found } @@ -595,3 +592,4 @@ module.exports.serializeUser = serializeUser; module.exports.deserializeUser = deserializeUser; module.exports.onLogin = onLogin; module.exports.onLogout = onLogout; +module.exports.setDb = mock => db = mock; diff --git a/lib/auth.test.js b/lib/auth.test.js index 03c2a082..3a9a22da 100644 --- a/lib/auth.test.js +++ b/lib/auth.test.js @@ -10,11 +10,11 @@ const filename = __filename.split('/').pop().split('.').shift(), passportGoogle = require('passport-google-oauth'), passportSlack = require('passport-slack'), passportAPIKey = require('passport-http-header-token'), - db = require('./services/db'), - responses = require('./responses'); + responses = require('./responses'), + storage = require('../test/fixtures/mocks/storage'); describe(_.startCase(filename), function () { - let sandbox; + let sandbox, db; beforeEach(function () { sandbox = sinon.sandbox.create(); @@ -26,7 +26,9 @@ describe(_.startCase(filename), function () { sandbox.stub(responses, 'unauthorized'); // ldap is called directly, so we can't stub it sandbox.stub(passportAPIKey, 'Strategy'); - sandbox.stub(db); + + db = storage(); + lib.setDb(db); }); afterEach(function () { @@ -130,7 +132,7 @@ describe(_.startCase(filename), function () { var done = sinon.spy(), returnedUser = {username: 'person', provider: 'google'}; - db.get.returns(Promise.resolve(JSON.stringify(returnedUser))); + db.get.returns(Promise.resolve(returnedUser)); fn('person', done) .then(function () { sinon.assert.calledOnce(done); @@ -238,14 +240,14 @@ describe(_.startCase(filename), function () { }); it('errors if user PUT fails on initial auth', function (done) { - db.get.returns(Promise.resolve(JSON.stringify({}))); + db.get.returns(Promise.resolve({})); db.put.returns(Promise.reject(new Error('some db error'))); fn(properties, siteStub)({ user: false }, null, null, profile, expectError(done)); }); it('assigns name and image if user found on initial auth', function (done) { - db.get.returns(Promise.resolve(JSON.stringify({}))); + db.get.returns(Promise.resolve({})); db.put.returns(Promise.resolve()); fn(properties, siteStub)({ user: false }, null, null, profile, expectData(done)); @@ -258,7 +260,7 @@ describe(_.startCase(filename), function () { }); it('grabs user data if user found on subsequent auth', function (done) { - db.get.returns(Promise.resolve(JSON.stringify(userData))); + db.get.returns(Promise.resolve(userData)); fn(properties, siteStub)({ user: true }, null, null, profile, expectData(done)); }); diff --git a/lib/bootstrap.js b/lib/bootstrap.js index dac0c49d..d74471cc 100644 --- a/lib/bootstrap.js +++ b/lib/bootstrap.js @@ -9,13 +9,13 @@ const path = require('path'), dbOps = require('./services/db-operations'), siteService = require('./services/sites'), references = require('./services/references'), - db = require('./services/db'), highland = require('highland'), { encode } = require('./services/buffer'); var log = require('./services/logger').setup({ file: __filename, action: 'bootstrap' }), + db = require('./services/db'), ERRORING_COMPONENTS = [ 'clay-kiln' // initialize with Kiln so we don't get an unecessary error message ]; @@ -337,3 +337,4 @@ module.exports.bootstrapPath = bootstrapPath; // For testing module.exports.setLog = mock => log = mock; +module.exports.setDb = mock => db = mock; diff --git a/lib/bootstrap.test.js b/lib/bootstrap.test.js index f3180d86..29c772a6 100644 --- a/lib/bootstrap.test.js +++ b/lib/bootstrap.test.js @@ -5,14 +5,15 @@ const _ = require('lodash'), path = require('path'), filename = __filename.split('/').pop().split('.').shift(), lib = require('./bootstrap'), - db = require('./services/db'), siteService = require('./services/sites'), expect = require('chai').expect, - sinon = require('sinon'); + sinon = require('sinon'), + storage = require('../test/fixtures/mocks/storage'); describe(_.startCase(filename), function () { let sandbox, bootstrapFake, // eslint-disable-line + db, sitesFake, fakeLog; @@ -33,6 +34,7 @@ describe(_.startCase(filename), function () { dir: 'example3', prefix: 'example1.com/some-path' }]; + bootstrapFake = files.getYaml(path.resolve('./test/fixtures/config/bootstrap')); }); @@ -43,6 +45,9 @@ describe(_.startCase(filename), function () { lib.setLog(fakeLog); sandbox.stub(siteService); siteService.sites.returns(_.cloneDeep(sitesFake)); + db = storage(); + lib.setDb(db); + db.batch.callsFake(db.batchToInMem); // we want to make sure to send to the actual in-mem batch return db.clear(); }); @@ -92,7 +97,6 @@ describe(_.startCase(filename), function () { describe('bootstrapPath', function () { const fn = lib[this.title]; - it('missing bootstrap', function (done) { fn('./jfkdlsa', sitesFake[0]) .then(done.bind(null, 'should throw')) @@ -108,14 +112,14 @@ describe(_.startCase(filename), function () { return fn('./test/fixtures/config/bootstrap', sitesFake[0]).then(function () { function expectKittehs(results) { + expect(results).to.deep.equal({ src: 'http://placekitten.com/400/600', alt: 'adorable kittens' }); } - return db.get(sitesFake[0].prefix + '/_components/image/instances/0') - .then(JSON.parse) + return db.getFromInMem(sitesFake[0].prefix + '/_components/image/instances/0') .then(expectKittehs); }); }); diff --git a/lib/plugins/index.js b/lib/plugins/index.js deleted file mode 100644 index 341b30c6..00000000 --- a/lib/plugins/index.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict'; - -const h = require('highland'), - _each = require('lodash/each'), - _isFunction = require('lodash/isFunction'), - _cloneDeep = require('lodash/cloneDeep'); - -/** - * Iterate through plugins and try to execute - * a function exported by any plugin that has - * been registered. - * - * @param {String} key - * @param {Any} args - */ -function executeHook(key, args) { - _each(module.exports.plugins, (plugin) => { - var streamApi = `${key}Stream`; - - if (_isFunction(plugin[key])) { - plugin[key](args); - } - - // If we have a Stream for this plugin, write to it - // but clone the ops to prevent bad mutations - if (h.isStream(plugin[streamApi])) { - plugin[streamApi].write(_cloneDeep(args)); - } - }); -} - -/** - * Register plugins passed in at instantiation time. - * Also fires the `init` hook. - * - * @param {Array} plugins [description]] - */ -function registerPlugins(plugins) { - module.exports.plugins = plugins; - - executeHook('init'); -} - -module.exports.plugins = []; -module.exports.registerPlugins = registerPlugins; -module.exports.executeHook = executeHook; diff --git a/lib/plugins/index.test.js b/lib/plugins/index.test.js deleted file mode 100644 index 73c3a88e..00000000 --- a/lib/plugins/index.test.js +++ /dev/null @@ -1,70 +0,0 @@ -'use strict'; - -const _ = require('lodash'), - h = require('highland'), - filename = __filename.split('/').pop().split('.').shift(), - lib = require('./' + filename), - expect = require('chai').expect, - sinon = require('sinon'); - -describe(_.startCase(filename), function () { - let sandbox; - - beforeEach(function () { - sandbox = sinon.sandbox.create(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - /** - * Return a fake plugin - * @return {Object} - */ - function pluginMock() { - return { - init: _.noop, - save: _.noop, - delete: _.noop - }; - } - - describe('registerPlugins', function () { - const fn = lib[this.title]; - - it('assigns an object passed in to the `plugins` value on module.exports', function () { - const pluginSample = [{ a: _.noop }, { b: _.noop }]; - - fn(pluginSample); - expect(lib.plugins).to.eql(pluginSample); - }); - }); - - describe('executeHook', function () { - it('calls', function () { - const plugin = sandbox.stub(pluginMock()); - - lib.registerPlugins([plugin]); - sinon.assert.calledOnce(plugin.init); - }); - - it('writes to Streams', function (done) { - const stream = h(), - streamPlugin = [{publishStream: stream}], - payload = {uri: 'fake/uri', ops: [{}]}; - - lib.registerPlugins(streamPlugin); - lib.executeHook('publish', payload); - stream.write(h.nil); - - stream - .collect() - .toPromise(Promise) - .then(function (resp) { - expect(resp).to.eql([payload]); // wrap in Array because of `.collect` - done(); - }); - }); - }); -}); diff --git a/lib/plugins/readme.md b/lib/plugins/readme.md deleted file mode 100644 index bf397ab9..00000000 --- a/lib/plugins/readme.md +++ /dev/null @@ -1,86 +0,0 @@ -Plugins -======= - -This is a technical document on plugins in Amphora. - -For a broader overview, please see the [project's readme document](https://github.com/nymag/amphora) - -## Registering plugins with Amphora - -Plugins are given when amphora is called. For example: - -```javascript -const amphora = require('amphora'), - app = require('express')(), - yourPlugin = require('yourPlugin'); - -amphora({ - app: app, - plugins: [ - yourPlugin - ] -}); -``` - -## API - -A plugin can add the following to its `module.exports`: - -`module.exports.init` - - runs once when the plugin is registered - - given no params - -`module.exports.routes` - - runs once when amphora adds routes for each site - - given one param: express router - -`module.exports.save` - - hook triggered on db put or db batch containing put(s) - - given db operations in one param: `[{type: string, key: string, value: string}]` - -`module.exports.delete` - - hook triggered on db delete or db batch containing delete(s) - - given db operations in one param: `[{type: string, key: string, value: string}]` - -`module.exports.publish` - - hook triggered on page publish (db batch with page@published) - - _note:_ when `publish` is triggered, `save` is also triggered - - given one param with uri and ops: `{ uri: pageUri, ops: [{type: put|del, key: string, value: string}] }` - -`module.exports.unpublish` - - hook triggered on page unpublish - - _note:_ when `unpublish` is triggered, `delete` is also triggered - - given one param: `{ url: pageUrl, uri: pageUri }` - -### Page-specific Methods - -Note that `publish` and `unpublish` are used by certain plugins, but they will be deprecated in a future major Amphora version in favor of `publishPage` and `unpublishPage` (which have more standardized arguments). - -`module.exports.createPage` - - hook triggered on page creation - - _note:_ when `createPage` is triggered, `save` is also triggered - - given one param with uri, data, and user: `{ uri: pageUri, data: pageData, user: req.user }` - -`module.exports.publishPage` - - hook triggered on page publish (db batch with page@published) - - _note:_ when `publishPage` is triggered, `save` and `publish` are also triggered - - given one param with uri, data, and user: `{ uri: pageUri, data: pageData, user: req.user }` - -`module.exports.unpublishPage` - - hook triggered on page unpublish - - _note:_ when `unpublishPage` is triggered, `delete` and `unpublish` are also triggered - - given one param with uri, url (that was just removed), and user: `{ uri: pageUri, url: pageURL, user: req.user }` - -`module.exports.schedulePage` - - hook triggered on page scheduling - - _note:_ when `schedulePage` is triggered, `save` is also triggered - - given one param with uri, data, and user: `{ uri: scheduledItemUri, data: { at: timestamp, publish: pageUri }, user: req.user }` - -`module.exports.unschedulePage` - - hook triggered on page unscheduling - - _note:_ when `unschedulePage` is triggered, `save` is also triggered - - given one param with uri, data, and user: `{ uri: scheduledItemUri, data: { at: timestamp, publish: pageUri }, user: req.user }` - -### Layout-specific Methods - -Layouts only have one hook, called `publishLayout`. It is called with the same arguments as `publishPage`, namely `{ uri, data, user }`. diff --git a/lib/render.js b/lib/render.js index aff860a0..ee750677 100644 --- a/lib/render.js +++ b/lib/render.js @@ -1,6 +1,7 @@ 'use strict'; var uriRoutes, renderers, + db = require('./services/db'), log = require('./services/logger').setup({ file: __filename, action: 'render' @@ -11,7 +12,6 @@ const _ = require('lodash'), components = require('./services/components'), references = require('./services/references'), clayUtils = require('clayutils'), - db = require('./services/db'), composer = require('./services/composer'), responses = require('./responses'), mapLayoutToPageData = require('./utils/layout-to-page-data'); @@ -55,7 +55,7 @@ function findRenderer(extension) { if (renderer) { return renderer; } else { - throw new Error(`Renderer not found for extension ${extension}`); + return new Error(`Renderer not found for extension ${extension}`); } } @@ -74,6 +74,10 @@ function renderComponent(req, res, hrStart) { locals = res.locals, options = options || {}; + if (renderer instanceof Error) { + log('error', renderer); + return Promise.reject(renderer); + } // Add request route params, request query params // and the request extension to the locals object locals.params = req.params; @@ -83,7 +87,8 @@ function renderComponent(req, res, hrStart) { return components.get(_ref, locals) .then(cmptData => formDataForRenderer(cmptData, { _ref }, locals)) .tap(logTime(hrStart, _ref)) - .then(({data, options}) => renderer.render(data, options, res)); + .then(({data, options}) => renderer.render(data, options, res)) + .catch(err => log('error', err)); } /** @@ -92,7 +97,7 @@ function renderComponent(req, res, hrStart) { * @return {Object} */ function getDBObject(uri) { - return db.get(uri).then(JSON.parse); + return db.get(uri); } /** @@ -117,7 +122,10 @@ function renderPage(uri, req, res, hrStart) { // look up page alias' component instance return getDBObject(uri) .then(formDataFromLayout(locals, uri)) - .tap(logTime(hrStart, uri)) + .then(data => { + logTime(hrStart, uri); + return data; + }) .then(({ data, options }) => renderer.render(data, options, res)) .catch(responses.handleError(res)); } @@ -363,3 +371,5 @@ module.exports.registerRenderers = registerRenderers; module.exports.resetUriRouteHandlers = resetUriRouteHandlers; module.exports.setUriRouteHandlers = setUriRouteHandlers; module.exports.assumePublishedUnlessEditing = assumePublishedUnlessEditing; +module.exports.setDb = mock => db = mock; +module.exports.setLog = mock => log = mock; \ No newline at end of file diff --git a/lib/render.test.js b/lib/render.test.js index 182b13df..c188d8e1 100644 --- a/lib/render.test.js +++ b/lib/render.test.js @@ -3,7 +3,6 @@ const _ = require('lodash'), bluebird = require('bluebird'), filename = __filename.split('/').pop().split('.').shift(), - db = require('./services/db'), lib = require('./' + filename), expect = require('chai').expect, sinon = require('sinon'), @@ -11,11 +10,13 @@ const _ = require('lodash'), components = require('./services/components'), composer = require('./services/composer'), createMockReq = require('../test/fixtures/mocks/req'), - createMockRes = require('../test/fixtures/mocks/res'); + createMockRes = require('../test/fixtures/mocks/res'), + storage = require('../test/fixtures/mocks/storage'); describe(_.startCase(filename), function () { - let sandbox, + let sandbox, db, + mockLog, mockSite = 'mockSite'; /** @@ -40,7 +41,7 @@ describe(_.startCase(filename), function () { * @returns {Promise.string} */ function resolveString(str) { - return bluebird.resolve(JSON.stringify(str)); + return bluebird.resolve(str); } /** @@ -74,10 +75,13 @@ describe(_.startCase(filename), function () { beforeEach(function () { sandbox = sinon.sandbox.create(); - sandbox.stub(db); + mockLog = sandbox.stub(); + db = storage(); + lib.setDb(db); sandbox.stub(components); sandbox.stub(composer); sandbox.stub(schema); + lib.setLog(mockLog); lib.resetUriRouteHandlers(); lib.registerRenderers({ default: 'html', @@ -162,30 +166,31 @@ describe(_.startCase(filename), function () { describe('renderComponent', function () { const fn = lib[this.title]; - it('throws if data not found, no logging', function (done) { - var req = getMockReq(); - - components.get.returns(bluebird.reject(new Error('thing'))); + it('throws if data not found, no logging', function () { + var req = getMockReq(), + error = new Error('thing'); - fn(req, getMockRes()).then(done.bind(null, 'should throw'), function () { - // should attempt to fetch the data, for sure. - sinon.assert.callCount(components.get, 1); + lib.registerRenderers({ html: _.noop }); + req.params.ext = 'html'; + components.get.returns(bluebird.reject(error)); - done(); - }); + return fn(req, getMockRes()) + .then(() => { + sinon.assert.calledWith(mockLog, 'error', error); + lib.registerRenderers(undefined); + }); }); - it('throws if renderer not found', function (done) { - var req = getMockReq(), result; + it('throws if renderer not found', function () { + var req = getMockReq(); req.params.ext = 'womp'; - components.get.returns(bluebird.reject(new Error('thing'))); - result = function () { - fn(req, getMockRes(), {}); - }; - expect(result).to.throw(Error); - done(); + return fn(req, getMockRes(), {}) + .catch(err => { + expect(err.message).to.equal('Renderer not found for extension womp'); + sinon.assert.calledOnce(mockLog); + }); }); it('renders and logs if data found', function () { diff --git a/lib/responses.js b/lib/responses.js index d2b83f71..89ca5793 100644 --- a/lib/responses.js +++ b/lib/responses.js @@ -1,19 +1,13 @@ -/** - * Collection of well-tested responses - * - * @module - */ - 'use strict'; -var log = require('./services/logger').setup({ - file: __filename -}); + +var db = require('./services/db'), + log = require('./services/logger').setup({ + file: __filename + }); const _ = require('lodash'), - db = require('./services/db'), bluebird = require('bluebird'), filter = require('through2-filter'), - map = require('through2-map'), - referencedProperty = '_ref'; + map = require('through2-map'); /** * Finds prefixToken, and removes it and anything before it. @@ -373,7 +367,8 @@ function acceptJSONOnly(req, res, next) { * @param {object} res */ function expectText(fn, res) { - bluebird.try(fn).then(function (result) { + bluebird.try(fn).then(result => { + res.set('Content-Type', 'text/plain'); res.send(result); }).catch(handleError(res)); } @@ -393,19 +388,6 @@ function expectJSON(fn, res) { }).catch(handleError(res)); } -/** - * @param {[{key: string, value: string}]} list - * @returns {object} - */ -function convertDBListToReferenceObjects(list) { - return _.map(list, function (obj) { - const item = JSON.parse(obj.value); - - item[referencedProperty] = obj.key; - return item; - }); -} - /** * List all things in the db * @param {object} [options] @@ -500,30 +482,13 @@ function listWithPublishedVersions(req, res) { return list(options)(req, res); } -/** - * @param {object} req - * @param {object} res - */ -function listAsReferencedObjects(req, res) { - expectJSON(function () { - return db.pipeToPromise(db.list({ - keys: true, - values: true, - isArray: true, - prefix: req.uri - })).then(JSON.parse).then(convertDBListToReferenceObjects); - }, res); -} - /** * This route gets straight from the db. * @param {object} req * @param {object} res */ function getRouteFromDB(req, res) { - expectJSON(function () { - return db.get(req.uri).then(JSON.parse); - }, res); + expectJSON(() => db.get(req.uri), res); } /** @@ -536,7 +501,7 @@ function getRouteFromDB(req, res) { */ function putRouteFromDB(req, res) { expectJSON(function () { - return db.put(req.uri, JSON.stringify(req.body)).return(req.body); + return db.put(req.uri, JSON.stringify(req.body)).then(() => req.body); }, res); } @@ -553,19 +518,34 @@ function putRouteFromDB(req, res) { * @param {object} res */ function deleteRouteFromDB(req, res) { - expectJSON(function () { + expectJSON(() => { return db.get(req.uri) - .then(JSON.parse) - .then(oldData => db.del(req.uri).return(oldData)); + .then(oldData => db.del(req.uri).then(() => oldData)); }, res); } +/** + * Forces a redirect back to the non-published + * version of a uri + * + * @param {Number} code + * @returns {Function} + */ +function forceEditableInstance(code = 303) { + return (req, res) => { + res.redirect(code, `${res.locals.site.protocol}://${req.uri.replace('@published', '')}`); + }; +} + // utility for routers module.exports.removeQueryString = removeQueryString; module.exports.removeExtension = removeExtension; module.exports.normalizePath = normalizePath; module.exports.getUri = getUri; +// redirect responses +module.exports.forceEditableInstance = forceEditableInstance; + // error responses module.exports.clientError = clientError; // 400 client error module.exports.unauthorized = unauthorized; // 401 unauthorized error @@ -591,12 +571,10 @@ module.exports.list = list; module.exports.listUsers = listUsers; module.exports.listWithPublishedVersions = listWithPublishedVersions; module.exports.listWithoutVersions = listWithoutVersions; -module.exports.listAsReferencedObjects = listAsReferencedObjects; module.exports.getRouteFromDB = getRouteFromDB; module.exports.putRouteFromDB = putRouteFromDB; module.exports.deleteRouteFromDB = deleteRouteFromDB; // For testing -module.exports.setLog = function (fakeLogger) { - log = fakeLogger; -}; +module.exports.setLog = mock => log = mock; +module.exports.setDb = mock => db = mock; diff --git a/lib/responses.test.js b/lib/responses.test.js index ea9fd6de..b8cd69b9 100644 --- a/lib/responses.test.js +++ b/lib/responses.test.js @@ -3,16 +3,16 @@ const _ = require('lodash'), filename = __filename.split('/').pop().split('.').shift(), lib = require('./' + filename), - db = require('./services/db'), expect = require('chai').expect, sinon = require('sinon'), bluebird = require('bluebird'), filter = require('through2-filter'), createMockReq = require('../test/fixtures/mocks/req'), - createMockRes = require('../test/fixtures/mocks/res'); + createMockRes = require('../test/fixtures/mocks/res'), + storage = require('../test/fixtures/mocks/storage'); describe(_.startCase(filename), function () { - let sandbox, fakeLog; + let sandbox, fakeLog, db; /** * Shortcut @@ -50,12 +50,18 @@ describe(_.startCase(filename), function () { sandbox = sinon.sandbox.create(); fakeLog = sandbox.stub(); lib.setLog(fakeLog); + db = storage(); + lib.setDb(db); }); afterEach(function () { sandbox.restore(); }); + after(function () { + return db.clearMem(); + }); + describe('removeQueryString', function () { const fn = lib[this.title]; @@ -296,15 +302,15 @@ describe(_.startCase(filename), function () { const fn = lib[this.title]; beforeEach(function () { - return db.clear().then(function () { + return db.clearMem().then(function () { return bluebird.join( - db.put('base.com/a', 'b'), - db.put('base.com/aa', 'b'), - db.put('base.com/aaa', 'b'), - db.put('base.com/c', 'd'), - db.put('base.com/cc', 'd'), - db.put('base.com/ccc', 'd'), - db.put('base.com/e', 'f') + db.writeToInMem('base.com/a', 'b'), + db.writeToInMem('base.com/aa', 'b'), + db.writeToInMem('base.com/aaa', 'b'), + db.writeToInMem('base.com/c', 'd'), + db.writeToInMem('base.com/cc', 'd'), + db.writeToInMem('base.com/ccc', 'd'), + db.writeToInMem('base.com/e', 'f') ); }); }); @@ -363,15 +369,15 @@ describe(_.startCase(filename), function () { ]; beforeEach(function () { - return db.clear().then(function () { + return db.clearMem().then(function () { return bluebird.join( - db.put('/_users/a', 'b'), - db.put('/_users/aa', 'b'), - db.put('/_users/aaa', 'b'), - db.put('/_users/c', 'd'), - db.put('/_users/cc', 'd'), - db.put('/_users/ccc', 'd'), - db.put('/_users/e', 'f') + db.writeToInMem('/_users/a', 'b'), + db.writeToInMem('/_users/aa', 'b'), + db.writeToInMem('/_users/aaa', 'b'), + db.writeToInMem('/_users/c', 'd'), + db.writeToInMem('/_users/cc', 'd'), + db.writeToInMem('/_users/ccc', 'd'), + db.writeToInMem('/_users/e', 'f') ); }); }); diff --git a/lib/routes.js b/lib/routes.js index 590e86d5..24447d4f 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -12,18 +12,22 @@ const _ = require('lodash'), path = require('path'), siteService = require('./services/sites'), attachRoutes = require('./services/attachRoutes'), - render = require('./render'), responses = require('./responses'), files = require('./files'), references = require('./services/references'), auth = require('./auth'), - plugins = require('./plugins'), + plugins = require('./services/plugins'), cors = { origins: '*', methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'].join(','), headers: ['Accept', 'Accept-Encoding', 'Authorization', 'Content-Type', 'Host', 'Referer', 'Origin', 'User-Agent', 'X-Forwarded-For', 'X-Forwarded-Host', 'X-Forwarded-Proto'].join(',') - }; + }, + routesPath = 'routes', + reservedRoutes = _.map(files.getFiles([__dirname, routesPath].join(path.sep)), responses.removeExtension); +var log = require('./services/logger').setup({ file: __filename }); + +attachRoutes.setReservedRoutes(reservedRoutes); /** * Add site.slug to locals for each site @@ -41,14 +45,12 @@ function addSiteLocals(site) { }; } -function addAssetDirectory(site, maxAge = 0) { +function addAssetDirectory(site) { if (!files.fileExists(site.assetDir)) { throw new Error('Asset directory does not exist: ' + site.assetDir); } - return express.static(site.assetDir, { - maxAge - }); + return express.static(site.assetDir); } /** @@ -131,13 +133,12 @@ function addUri(req, res, next) { * @param {express.Router} router */ function addControllerRoutes(router) { - const routesPath = 'routes'; // load all controller routers - _.each(files.getFiles([__dirname, routesPath].join(path.sep)), function (filename) { + _.each(reservedRoutes, function (routeName) { let pathRouter, - name = responses.removeExtension(filename), - controller = files.tryRequire([__dirname, routesPath, name].join(path.sep)); + filename = routeName + '.js', + controller = files.tryRequire([__dirname, routesPath, filename].join(path.sep)); // we're okay with an error occurring here because it means we're missing something important in a route pathRouter = express.Router(); @@ -148,7 +149,7 @@ function addControllerRoutes(router) { controller(pathRouter); - router.use('/' + name, pathRouter); + router.use('/' + routeName, pathRouter); }); } @@ -189,12 +190,10 @@ function addSiteController(router, site, providers) { const controller = files.tryRequire(site.dir); if (controller) { - if (_.isFunction(controller)) { - controller(router, render, site); - } - - if (!_.isFunction(controller) && Array.isArray(controller.routes)) { + if (Array.isArray(controller.routes)) { attachRoutes(router, controller.routes, site); + } else { + log('warn', `There is no router for site: ${site.slug}`); } // Providers are the same across all sites, but this is a convenient @@ -214,8 +213,9 @@ function addSiteController(router, site, providers) { * @param {express.Router} router * @param {Object} options * @param {Object} site + * @returns {Promise} */ -function addSite(router, { providers, sessionStore, cacheControl = {} }, site) { +function addSite(router, { providers, sessionStore }, site) { site = _.defaults(site, { path: '/' }); // If path isn't explicitly set, set it. const path = site.path, siteRouter = express.Router(); @@ -228,7 +228,7 @@ function addSite(router, { providers, sessionStore, cacheControl = {} }, site) { siteRouter.use(addUser); siteRouter.use(addUri); siteRouter.use(addSiteLocals(site)); - siteRouter.use(addAssetDirectory(site, cacheControl.staticMaxAge)); + siteRouter.use(addAssetDirectory(site)); siteRouter.use(addCORS(site)); // add query params to res.locals @@ -241,15 +241,17 @@ function addSite(router, { providers, sessionStore, cacheControl = {} }, site) { siteRouter.use(addAvailableComponents); // Plugins can add routes to a site, fire that hook now to register those routes - plugins.executeHook('routes', siteRouter); + return plugins.initPlugins(siteRouter) + .then(() => { + // amphora api routes added first so they're handled before site-specific routes which are generally registering less specific paths + addControllerRoutes(siteRouter); - // optional module to load routes and configuration defined from outside of amphora - router.use(path, addSiteController(siteRouter, site, providers)); + // optional module to load routes and configuration defined from outside of amphora + router.use(path, addSiteController(siteRouter, site, providers)); - // amphora api routes added afterwards, so they can change things about the site routes for additional functionality - addControllerRoutes(siteRouter); - router.use(path, siteRouter); + router.use(path, siteRouter); + }); } /** @@ -334,27 +336,25 @@ function addHost(options) { * @param {express.Router} router - Often an express app. * @param {Array} [providers] * @param {Object} [sessionStore] - * @param {Object} [cacheControl] * @returns {*} * @example * var app = express(); * require('./routes)(app); */ -function loadFromConfig(router, providers, sessionStore, cacheControl) { +function loadFromConfig(router, providers, sessionStore) { const sitesMap = siteService.sites(), siteHosts = _.uniq(_.map(sitesMap, 'host')); // iterate through the hosts - _.each(siteHosts, function (hostname) { - const sites = _.filter(sitesMap, { host: hostname }).sort(sortByDepthOfPath); + _.each(siteHosts, hostname => { + const sites = _.filter(sitesMap, {host: hostname}).sort(sortByDepthOfPath); addHost({ router, hostname, sites, providers, - sessionStore, - cacheControl + sessionStore }); }); @@ -370,3 +370,5 @@ module.exports.addCORS = addCORS; module.exports.addAvailableRoutes = addAvailableRoutes; module.exports.addAvailableComponents = addAvailableComponents; module.exports.addSiteController = addSiteController; +// For testing +module.exports.setLog = mock => log = mock; diff --git a/lib/routes.test.js b/lib/routes.test.js index 223a2893..39d60faf 100644 --- a/lib/routes.test.js +++ b/lib/routes.test.js @@ -5,7 +5,6 @@ const _ = require('lodash'), expect = require('chai').expect, sinon = require('sinon'), files = require('./files'), - path = require('path'), siteService = require('./services/sites'), auth = require('./auth'), express = require('express'); @@ -22,10 +21,13 @@ function createMockRouter() { } describe(_.startCase(filename), function () { - let sandbox; + let sandbox, fakeLog; beforeEach(function () { sandbox = sinon.sandbox.create(); + fakeLog = sandbox.stub(); + + lib.setLog(fakeLog); }); afterEach(function () { @@ -178,8 +180,7 @@ describe(_.startCase(filename), function () { files.getFiles.returns(['a.b, c.d, e.f']); fn(router, {providers: [], sessionStore: null}, {slug: 'example', assetDir: 'example'}); - - sinon.assert.calledWith(files.tryRequire, [__dirname, 'routes', 'a'].join(path.sep)); + sinon.assert.notCalled(files.tryRequire); }); it('inits auth if there are providers', function () { diff --git a/lib/routes/_components.js b/lib/routes/_components.js index 3529aa05..280fb46b 100644 --- a/lib/routes/_components.js +++ b/lib/routes/_components.js @@ -67,9 +67,7 @@ route = _.bindAll({ * @param {object} res */ get(req, res) { - responses.expectJSON(function () { - return controller.get(req.uri, res.locals); - }, res); + responses.expectJSON(() => controller.get(req.uri, res.locals), res); }, /** @@ -85,9 +83,7 @@ route = _.bindAll({ * @param {object} res */ published(req, res) { - responses.expectJSON(function () { - return controller.publish(req.uri, req.body, res.locals); - }, res); + responses.expectJSON(() => controller.publish(req.uri, req.body, res.locals), res); }, /** @@ -95,19 +91,7 @@ route = _.bindAll({ * @param {object} res */ put(req, res) { - responses.expectJSON(function () { - return controller.put(req.uri, req.body, res.locals); - }, res); - }, - - /** - * @param {object} req - * @param {object} res - */ - del(req, res) { - responses.expectJSON(function () { - return controller.del(req.uri, res.locals); - }, res); + responses.expectJSON(() => controller.put(req.uri, req.body, res.locals), res); }, /** @@ -115,9 +99,7 @@ route = _.bindAll({ * @param {object} res */ post(req, res) { - responses.expectJSON(function () { - return controller.post(req.uri, req.body, res.locals); - }, res); + responses.expectJSON(() => controller.post(req.uri, req.body, res.locals), res); }, /** @@ -151,9 +133,7 @@ route = _.bindAll({ putExtension(req, res) { return responses.expectJSON(function () { return controller.put(req.uri, req.body, res.locals) - .then(function () { - return getComposed(req.uri, res.locals); - }); + .then(() => getComposed(req.uri, res.locals)); }, res); }, @@ -182,7 +162,6 @@ route = _.bindAll({ 'list', 'published', 'put', - 'del', 'post', 'getExtension', 'putExtension', @@ -240,25 +219,19 @@ function routes(router) { router.put('/:name/instances/:id.json', withAuthLevel(authLevels.WRITE)); router.put('/:name/instances/:id.json', route.putExtension); - router.all('/:name/instances/:id@:version', responses.acceptJSONOnly); - router.all('/:name/instances/:id@:version', responses.methodNotAllowed({ + router.all('/:name/instances/:id@published', responses.acceptJSONOnly); + router.all('/:name/instances/:id@published', responses.methodNotAllowed({ allow: ['get', 'put', 'delete'] })); - router.all('/:name/instances/:id@:version', responses.denyTrailingSlashOnId); - router.get('/:name/instances/:id@:version', route.get); - router.get('/:name/instances/:id@:version.:ext', route.get); - // Layouts can be scheduled - router.put('/:name/instances/:id@scheduled', responses.denyReferenceAtRoot); - router.put('/:name/instances/:id@scheduled', withAuthLevel(authLevels.WRITE)); - router.put('/:name/instances/:id@scheduled', route.put); + router.all('/:name/instances/:id@published', responses.denyTrailingSlashOnId); + router.get('/:name/instances/:id@published', route.get); + router.get('/:name/instances/:id@published.:ext', route.getExtension); // Any component can be published router.put('/:name/instances/:id@published', responses.denyReferenceAtRoot); router.put('/:name/instances/:id@published', withAuthLevel(authLevels.WRITE)); router.put('/:name/instances/:id@published', route.published); - router.delete('/:name/instances/:id@scheduled', withAuthLevel(authLevels.WRITE)); - router.delete('/:name/instances/:id@scheduled', route.del); - router.delete('/:name/instances/:id@:version', withAuthLevel(authLevels.ADMIN)); - router.delete('/:name/instances/:id@:version', route.del); + router.delete('/:name/instances/:id@published', withAuthLevel(authLevels.ADMIN)); + router.delete('/:name/instances/:id@published', responses.deleteRouteFromDB); router.all('/:name/instances/:id', responses.acceptJSONOnly); router.all('/:name/instances/:id', responses.methodNotAllowed({ @@ -270,11 +243,11 @@ function routes(router) { router.put('/:name/instances/:id', withAuthLevel(authLevels.WRITE)); router.put('/:name/instances/:id', route.put); router.delete('/:name/instances/:id', withAuthLevel(authLevels.ADMIN)); - router.delete('/:name/instances/:id', route.del); + router.delete('/:name/instances/:id', responses.deleteRouteFromDB); // Need to have the delete route instance below or else it'll catch everything router.delete('/:name', withAuthLevel(authLevels.ADMIN)); - router.delete('/:name', route.del); + router.delete('/:name', responses.deleteRouteFromDB); router.all('/:name/schema', responses.methodNotAllowed({ allow: ['get'] diff --git a/lib/routes/_layouts.js b/lib/routes/_layouts.js new file mode 100644 index 00000000..aeb828c7 --- /dev/null +++ b/lib/routes/_layouts.js @@ -0,0 +1,337 @@ +'use strict'; + +const _ = require('lodash'), + responses = require('../responses'), + render = require('../render'), + bus = require('../services/bus'), + db = require('../services/db'), + files = require('../files'), + metaController = require('../services/metadata'), + { getSchema } = require('../utils/schema'), + { withAuthLevel, authLevels } = require('../auth'), + controller = require('../services/layouts'); + +let validation, route; + +/** + * get composed component data + * @param {string} uri of root component + * @param {object} locals + * @return {Promise} composed data of root component and children + */ +function getComposed(uri, locals) { + return controller.get(uri, locals).then(data => { + // TODO: Check that we can't just reference the composer + // require here because otherwise it's a circular dependency (via html-composer) + return require('../services/composer').resolveComponentReferences(data, locals); + }); +} + +/** + * Validation of component routes goes here. + * + * They will all have the form (req, res, next). + * + * @namespace + */ +validation = _.bindAll({ + /** + * If component doesn't exist, then the resource cannot be found. + * + * @param {object} req + * @param {object} res + * @param {Function} next + */ + componentMustExist(req, res, next) { + let name = req.params.name; + + name = name.split('@')[0]; + name = name.split('.')[0]; + + if (!!files.getLayoutPath(name)) { + next(); + } else { + responses.notFound(res); + } + } +}, ['componentMustExist']); + +/** + * All routes go here. + * + * They will all have the form (req, res), but never with next() + * + * @namespace + */ +route = _.bindAll({ + + /** + * @param {object} req + * @param {object} res + */ + get(req, res) { + responses.expectJSON(() => controller.get(req.uri, res.locals), res); + }, + + /** + * @param {object} req + * @param {object} res + */ + list(req, res) { + responses.expectJSON(function () { + return files.getLayouts(); + }, res); + }, + + /** + * @param {object} req + * @param {object} res + */ + published(req, res) { + responses.expectJSON(function () { + return controller.publish(req.uri, req.body, res.locals); + }, res); + }, + + /** + * @param {object} req + * @param {object} res + */ + put(req, res) { + const data = req.body, + user = res.locals && res.locals.user, + uri = req.uri; + + responses.expectJSON(() => { + return db.getLatestData(uri) + .then(() => controller.put(uri, data, res.locals)) + .catch(() => { + return controller.put(uri, data, res.locals) + .then(resp => metaController.createLayout(uri, user).then(() => { + bus.publish('createLayout', JSON.stringify({ uri, data, user })); + return resp; + })); + }); + }, res); + }, + + /** + * @param {object} req + * @param {object} res + */ + post(req, res) { + responses.expectJSON(() => controller.post(req.uri, req.body, res.locals), res); + }, + + /** + * GET returning html or json depending on extension + * + * Fail if they don't accept right protocol and not * + * + * @param {object} req + * @param {object} res + * @returns {Function} + */ + getExtension(req, res) { + // Check if the extension is included in the renderers + // that have been registered for the application + if (render.rendererExists(req.params.ext.toLowerCase())) { + this.render(req, res); + } else { + // Otherwise let's send back JSON as the response + return responses.expectJSON(function () { + return getComposed(req.uri, res.locals); + }, res); + } + }, + + /** + * PUT returning html or json depending on extension + * @param {object} req + * @param {object} res + * @returns {Function} + */ + putExtension(req, res) { + return responses.expectJSON(function () { + return controller.put(req.uri, req.body, res.locals) + .then(function () { + return getComposed(req.uri, res.locals); + }); + }, res); + }, + + /** + * Return a schema for a component + * + * @param {object} req + * @param {object} res + */ + schema(req, res) { + responses.expectJSON(() => getSchema(req.uri), res); + }, + + /** + * Render a component + * + * @param {Object} req + * @param {Object} res + * + */ + render(req, res) { + render.renderComponent(req, res, process.hrtime()); + }, + + /** + * Overwrite the metadata for a particular + * layout instance + * + * @param {Object} req + * @param {Object} res + */ + putMeta(req, res) { + responses.expectJSON(() => metaController.putMeta(req.uri, req.body), res); + }, + + /** + * Retrieve the metadata for a particular + * layout instance + * + * @param {Object} req + * @param {Object} res + */ + getMeta(req, res) { + responses.expectJSON(() => metaController.getMeta(req.uri), res); + }, + + /** + * Overwrite the specific properties in the + * metadata for a particular layout instance + * + * @param {Object} req + * @param {Object} res + */ + patchMeta(req, res) { + responses.expectJSON(() => metaController.patchMeta(req.uri, req.body), res); + } +}, [ + 'get', + 'list', + 'published', + 'put', + 'post', + 'getExtension', + 'putExtension', + 'schema', + 'render', + 'putMeta', + 'getMeta', + 'patchMeta' +]); + +function routes(router) { + router.use(responses.varyWithoutExtension({ + varyBy: ['Accept'] + })); + router.use(responses.onlyCachePublished); + + router.all('/', responses.methodNotAllowed({ + allow: ['get'] + })); + + router.all('/', responses.notAcceptable({ + accept: ['application/json'] + })); + router.get('/', route.list); + + router.all('/:name*', validation.componentMustExist); + router.get('/:name.:ext', route.getExtension); + + router.all('/:name', responses.acceptJSONOnly); + router.all('/:name', responses.methodNotAllowed({ + allow: ['get', 'put', 'delete'] + })); + router.all('/:name', responses.denyTrailingSlashOnId); + router.get('/:name', route.get); + router.put('/:name', responses.denyReferenceAtRoot); + router.put('/:name', withAuthLevel(authLevels.WRITE)); + router.put('/:name', route.put); + + router.all('/:name/instances', responses.acceptJSONOnly); + router.all('/:name/instances', responses.methodNotAllowed({ + allow: ['get', 'post'] + })); + router.get('/:name/instances', responses.listWithoutVersions()); + router.post('/:name/instances', responses.denyReferenceAtRoot); + router.post('/:name/instances', withAuthLevel(authLevels.WRITE)); + router.post('/:name/instances', route.post); + + router.get('/:name/instances/@published', responses.listWithPublishedVersions); + + router.all('/:name/instances/:id.:ext', responses.methodNotAllowed({ + allow: ['get', 'put'] + })); + + // We let any extension be retrieved because we can have different renderers, + // but we only let `PUT` requests come through root component instances + // or `.json` extensions + router.get('/:name/instances/:id.:ext', route.getExtension); + router.put('/:name/instances/:id.json', responses.denyReferenceAtRoot); + router.put('/:name/instances/:id.json', withAuthLevel(authLevels.WRITE)); + router.put('/:name/instances/:id.json', route.putExtension); + + router.all('/:name/instances/:id@published', responses.acceptJSONOnly); + router.all('/:name/instances/:id@published', responses.methodNotAllowed({ + allow: ['get', 'put', 'delete'] + })); + router.all('/:name/instances/:id@published', responses.denyTrailingSlashOnId); + router.get('/:name/instances/:id@published', route.get); + router.get('/:name/instances/:id@published.:ext', route.getExtension); + // Any component can be published + router.put('/:name/instances/:id@published', responses.denyReferenceAtRoot); + router.put('/:name/instances/:id@published', withAuthLevel(authLevels.WRITE)); + router.put('/:name/instances/:id@published', route.published); + router.delete('/:name/instances/:id@published', withAuthLevel(authLevels.ADMIN)); + router.delete('/:name/instances/:id@published', responses.deleteRouteFromDB); + + router.all('/:name/instances/:id', responses.acceptJSONOnly); + router.all('/:name/instances/:id', responses.methodNotAllowed({ + allow: ['get', 'put', 'delete'] + })); + router.all('/:name/instances/:id', responses.denyTrailingSlashOnId); + router.get('/:name/instances/:id', route.get); + router.put('/:name/instances/:id', responses.denyReferenceAtRoot); + router.put('/:name/instances/:id', withAuthLevel(authLevels.WRITE)); + router.put('/:name/instances/:id', route.put); + router.delete('/:name/instances/:id', withAuthLevel(authLevels.ADMIN)); + router.delete('/:name/instances/:id', responses.deleteRouteFromDB); + + // Meta routes + router.all('/:name/instances/:id@published/meta', responses.methodNotAllowed({allow: ['get']})); + router.get('/:name/instances/:id@published/meta', responses.forceEditableInstance()); + + router.all('/:name/instances/:id/meta', responses.methodNotAllowed({allow: ['get', 'put', 'patch']})); + router.all('/:name/instances/:id/meta', responses.notAcceptable({accept: ['application/json']})); + router.all('/:name/instances/:id/meta', responses.denyTrailingSlashOnId); + router.get('/:name/instances/:id/meta', route.getMeta); + router.put('/:name/instances/:id/meta', responses.denyReferenceAtRoot); + router.put('/:name/instances/:id/meta', withAuthLevel(authLevels.WRITE)); + router.put('/:name/instances/:id/meta', route.putMeta); + router.patch('/:name/instances/:id/meta', responses.denyReferenceAtRoot); + router.patch('/:name/instances/:id/meta', withAuthLevel(authLevels.WRITE)); + router.patch('/:name/instances/:id/meta', route.patchMeta); + + // Need to have the delete route instance below or else it'll catch everything + router.delete('/:name', withAuthLevel(authLevels.ADMIN)); + router.delete('/:name', responses.deleteRouteFromDB); + + router.all('/:name/schema', responses.methodNotAllowed({ + allow: ['get'] + })); + router.all('/:name/schema', responses.notAcceptable({ + accept: ['application/json'] + })); + router.get('/:name/schema', route.schema); +} + +module.exports = routes; +module.exports.route = route; // For testing of custom rendering diff --git a/lib/routes/_lists.js b/lib/routes/_lists.js index 0f26929e..b0a3e638 100644 --- a/lib/routes/_lists.js +++ b/lib/routes/_lists.js @@ -1,9 +1,3 @@ -/** - * Controller for URIs - * - * @module - */ - 'use strict'; const _ = require('lodash'), diff --git a/lib/routes/_pages.js b/lib/routes/_pages.js index 1ab17561..5a63ba91 100644 --- a/lib/routes/_pages.js +++ b/lib/routes/_pages.js @@ -10,6 +10,7 @@ const _ = require('lodash'), responses = require('../responses'), render = require('../render'), controller = require('../services/pages'), + metaController = require('../services/metadata'), composer = require('../services/composer'), db = require('../services/db'), { withAuthLevel, authLevels } = require('../auth'); @@ -22,7 +23,6 @@ const _ = require('lodash'), */ function getComposed(uri, locals) { return db.get(uri) - .then(JSON.parse) .then(pageData => composer.composePage(pageData, locals)); } @@ -39,7 +39,7 @@ let route = _.bindAll({ * @param {object} res */ post(req, res) { - responses.expectJSON(function () { + responses.expectJSON(() => { return controller.create(req.uri, req.body, res.locals) .then(function (result) { // creation success! @@ -53,18 +53,14 @@ let route = _.bindAll({ * @param {object} res */ putPublish(req, res) { - responses.expectJSON(function () { - return controller.publish(req.uri, req.body, res.locals); - }, res); + responses.expectJSON(() => controller.publish(req.uri, req.body, res.locals), res); }, /** * @param {object} req * @param {object} res */ putLatest(req, res) { - responses.expectJSON(function () { - return controller.putLatest(req.uri, req.body, res.locals); - }, res); + responses.expectJSON(() => controller.putLatest(req.uri, req.body, res.locals), res); }, /** * Change the acceptance type based on the extension they gave us @@ -95,13 +91,46 @@ let route = _.bindAll({ */ render(req, res) { render.renderPage(req.uri, req, res, process.hrtime()); + }, + /** + * Overwrite the metadata for a particular + * page instance + * + * @param {Object} req + * @param {Object} res + */ + putMeta(req, res) { + responses.expectJSON(() => metaController.putMeta(req.uri, req.body), res); + }, + /** + * Retrieve the metadata for a particular + * page instance + * + * @param {Object} req + * @param {Object} res + */ + getMeta(req, res) { + responses.expectJSON(() => metaController.getMeta(req.uri), res); + }, + /** + * Overwrite the specific properties in the + * metadata for a particular page instance + * + * @param {Object} req + * @param {Object} res + */ + patchMeta(req, res) { + responses.expectJSON(() => metaController.patchMeta(req.uri, req.body), res); } }, [ 'post', 'putPublish', 'putLatest', 'extension', - 'render' + 'render', + 'putMeta', + 'getMeta', + 'patchMeta' ]); function routes(router) { @@ -141,7 +170,21 @@ function routes(router) { router.put('/:name', withAuthLevel(authLevels.WRITE)); router.put('/:name', route.putLatest); router.delete('/:name', withAuthLevel(authLevels.ADMIN)); - router.delete('/:name', responses.deleteRouteFromDB); + router.delete('/:name', responses.deleteRouteFromDB); // TODO: DELETE META AS WELL + + router.all('/:name@published/meta', responses.methodNotAllowed({allow: ['get']})); + router.get('/:name@published/meta', responses.forceEditableInstance()); + + router.all('/:name/meta', responses.methodNotAllowed({allow: ['get', 'put', 'patch']})); + router.all('/:name/meta', responses.notAcceptable({accept: ['application/json']})); + router.all('/:name/meta', responses.denyTrailingSlashOnId); + router.get('/:name/meta', route.getMeta); + router.put('/:name/meta', responses.denyReferenceAtRoot); + router.put('/:name/meta', withAuthLevel(authLevels.WRITE)); + router.put('/:name/meta', route.putMeta); + router.patch('/:name/meta', responses.denyReferenceAtRoot); + router.patch('/:name/meta', withAuthLevel(authLevels.WRITE)); + router.patch('/:name/meta', route.patchMeta); } module.exports = routes; diff --git a/lib/routes/_schedule.js b/lib/routes/_schedule.js deleted file mode 100644 index 16c03244..00000000 --- a/lib/routes/_schedule.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Controller for Pages - * - * @module - */ - -'use strict'; - -const _ = require('lodash'), - responses = require('../responses'), - controller = require('../services/schedule'), - { withAuthLevel, authLevels } = require('../auth'); - -/** - * All routes go here. - * - * They will all have the form (req, res), but never with next() - * - * @namespace - */ -let route = _.bindAll({ - - /** - * @param {object} req - * @param {object} res - */ - post(req, res) { - responses.expectJSON(function () { - return controller.post(req.uri, req.body, req.user) - .then(function (result) { - // creation success! - res.status(201); - return result; - }); - }, res); - }, - - /** - * @param {object} req - * @param {object} res - */ - del(req, res) { - responses.expectJSON(function () { - return controller.del(req.uri, req.user); - }, res); - } -}, ['post', 'del']); - -function routes(router) { - router.use(responses.varyWithoutExtension({varyBy: ['Accept']})); - router.use(responses.onlyCachePublished); - - router.all('/', responses.methodNotAllowed({allow: ['get', 'post']})); - router.all('/', responses.notAcceptable({accept: ['application/json']})); - router.get('/', responses.listAsReferencedObjects); - router.post('/', withAuthLevel(authLevels.WRITE)); - router.post('/', route.post); - - router.all('/:name', responses.methodNotAllowed({allow: ['get', 'delete']})); - router.all('/:name', responses.notAcceptable({accept: ['application/json']})); - router.get('/:name', responses.getRouteFromDB); - router.delete('/:name', withAuthLevel(authLevels.WRITE)); - router.delete('/:name', route.del); -} - -module.exports = routes; diff --git a/lib/routes/_uris.js b/lib/routes/_uris.js index 06f406f6..28a57644 100644 --- a/lib/routes/_uris.js +++ b/lib/routes/_uris.js @@ -8,6 +8,7 @@ const responses = require('../responses'), controller = require('../services/uris'), + db = require('../services/db'), { withAuthLevel, authLevels } = require('../auth'); /** @@ -15,9 +16,7 @@ const responses = require('../responses'), * @param {object} res */ function getUriFromReference(req, res) { - responses.expectText(function () { - return controller.get(req.uri); - }, res); + responses.expectText(() => db.get(req.uri), res); } /** @@ -35,9 +34,7 @@ function putUriFromReference(req, res) { * @param {object} res */ function deleteUriFromReference(req, res) { - responses.expectText(function () { - return controller.del(req.uri, req.user); - }, res); + responses.expectText(() => controller.del(req.uri, req.user), res); } function routes(router) { diff --git a/lib/routes/_users.js b/lib/routes/_users.js index 4af5bd69..300bdabf 100644 --- a/lib/routes/_users.js +++ b/lib/routes/_users.js @@ -9,6 +9,8 @@ const _ = require('lodash'), responses = require('../responses'), controller = require('../services/users'), + db = require('../services/db'), + bus = require('../services/bus'), { withAuthLevel, authLevels } = require('../auth'); /** @@ -24,12 +26,33 @@ let route = _.bindAll({ * @param {object} res */ createUser(req, res) { - responses.expectJSON(function () { + responses.expectJSON(() => { return controller.createUser(req.body); }, res); + }, + + /** + * Remove the user and tell the bus + * + * @param {Object} req + * @param {Object} res + * @returns {Promise} + */ + deleteUser(req, res) { + return responses.expectJSON(() => { + return db.get(req.uri) + .then(oldData => { + return db.del(req.uri) + .then(() => { + bus.publish('deleteUser', JSON.stringify({ uri: req.uri })); + return oldData; + }); + }); + }, res); } }, [ - 'createUser' + 'createUser', + 'deleteUser' ]); /** @@ -69,7 +92,7 @@ function routes(router) { router.put('/:name', withAuthLevel(authLevels.ADMIN)); router.put('/:name', route.createUser); router.delete('/:id', withAuthLevel(authLevels.ADMIN)); - router.delete('/:id', responses.deleteRouteFromDB); + router.delete('/:id', route.deleteUser); } module.exports = routes; diff --git a/lib/services/attachRoutes.js b/lib/services/attachRoutes.js index 6a55d90c..fa52e616 100644 --- a/lib/services/attachRoutes.js +++ b/lib/services/attachRoutes.js @@ -1,6 +1,35 @@ 'use strict'; -const render = require('../render'); +const render = require('../render'), + _ = require('lodash'); + +let reservedRoutes = [], + log = require('./logger').setup({ file: __filename }); + +/** + * Checks for validity of a route to be attached + * + * @param {String} path + * @param {Object} site + * @returns {Boolean} + */ +function validPath(path, site) { + let reservedRoute; + + if (path[0] !== '/') { + log('warn', `Cannot attach route '${path}' for site ${site.slug}. Path must begin with a slash.`); + return false; + } + + reservedRoute = _.find(reservedRoutes, (route) => path.indexOf(route) === 1); + + if (reservedRoute) { + log('warn', `Cannot attach route '${path}' for site ${site.slug}. Route prefix /${reservedRoute} is reserved by Amphora.`); + return false; + } + + return true; +} /** * Normalizes redirects path appending site's path to it on sub-sites' redirects. @@ -60,7 +89,11 @@ function attachDynamicRoute(router, { path, dynamicPage }) { * @return {Router} */ function parseHandler(router, routeObj, site) { - const { redirect, dynamicPage } = routeObj; + const { redirect, dynamicPage, path } = routeObj; + + if (!validPath(path, site)) { + return; + } if (redirect) { return attachRedirect(router, routeObj, site); @@ -76,6 +109,7 @@ function parseHandler(router, routeObj, site) { * @param {Router} router * @param {Object[]} routes * @param {Object} site + * @param {Array} reservedRoutes * @return {Router} */ function attachRoutes(router, routes = [], site) { @@ -86,7 +120,17 @@ function attachRoutes(router, routes = [], site) { return router; } -module.exports = attachRoutes; +/** + * sets the global reservedRoutes list + * + * @param {Array} reserved + */ +function setReservedRoutes(reserved) { + reservedRoutes = reserved; +} -// Exposed for testing +module.exports = attachRoutes; +module.exports.setReservedRoutes = setReservedRoutes; +// For testing module.exports.normalizeRedirectPath = normalizeRedirectPath; +module.exports.setLog = mock => log = mock; diff --git a/lib/services/attachRoutes.test.js b/lib/services/attachRoutes.test.js index 34a56a38..b24a4f6d 100644 --- a/lib/services/attachRoutes.test.js +++ b/lib/services/attachRoutes.test.js @@ -16,10 +16,13 @@ describe(_.startCase(filename), function () { ], siteConfigMock = {}; - let sandbox; + let sandbox, fakeLog; beforeEach(function () { sandbox = sinon.sandbox.create(); + fakeLog = sandbox.stub(); + + lib.setLog(fakeLog); }); afterEach(function () { @@ -59,6 +62,45 @@ describe(_.startCase(filename), function () { lib(router, testRoutes, siteConfigMock); sinon.assert.calledOnce(fakeRes.redirect); }); + + it('does not attach an invalid route path', function () { + const paths = [], + testRoutes = [ + { path: '/section' }, + { path: 'bad-path/article' } + ], + router = { + get(path) { + // testing if the paths are added, + // we're checking the paths array after each test + paths.push(path); + } + }; + + lib(router, testRoutes, siteConfigMock); + expect(paths).to.not.include('bad-path/article'); + sinon.assert.calledWith(fakeLog, 'warn'); + }); + + it('does not attach a reserved route path', function () { + const paths = [], + testRoutes = [ + { path: '/section' }, + { path: '/_components/wow' } + ], + router = { + get(path) { + // testing if the paths are added, + // we're checking the paths array after each test + paths.push(path); + } + }; + + lib.setReservedRoutes(['_components']); + lib(router, testRoutes, siteConfigMock); + expect(paths).to.not.include('/_components/wow'); + sinon.assert.calledWith(fakeLog, 'warn'); + }); }); describe('normalizeRedirectPath', () => { diff --git a/lib/services/components.js b/lib/services/components.js index 5bef040e..0485986c 100644 --- a/lib/services/components.js +++ b/lib/services/components.js @@ -2,17 +2,11 @@ const _ = require('lodash'), composer = require('./composer'), - db = require('./db'), uid = require('../uid'), files = require('../files'), - schema = require('../schema'), - bluebird = require('bluebird'), models = require('./models'), dbOps = require('./db-operations'), - { getComponentName, replaceVersion } = require('clayutils'), - plugins = require('../plugins'), - bus = require('./bus'), - referenceProperty = '_ref'; + { getComponentName, replaceVersion } = require('clayutils'); /** * @param {string} uri @@ -21,19 +15,26 @@ const _ = require('lodash'), */ function get(uri, locals) { const name = getComponentName(uri), - reqExtension = _.get(locals, 'extension'), model = name && files.getComponentModule(name), callComponentHooks = _.get(locals, 'hooks') !== 'false', - executeRender = model && _.isFunction(model.render) && callComponentHooks, - renderModel = reqExtension && files.getComponentModule(name, reqExtension); + reqExtension = _.get(locals, 'extension'), + renderModel = reqExtension && files.getComponentModule(name, reqExtension), + executeRender = model && _.isFunction(model.render) && callComponentHooks; return models.get(model, renderModel, executeRender, uri, locals); } /** - * @param {string} uri - * @param {string} data - * @param {object} [locals] + * Given a component uri, its data and the locals + * check if there exists a model.js file for the + * component. + * + * If yes, run the model.js. If not, turn the component + * data into ops. + * + * @param {String} uri + * @param {String} data + * @param {Object} [locals] * @returns {Promise} */ function put(uri, data, locals) { @@ -50,116 +51,42 @@ function put(uri, data, locals) { return result; } -/** - * True if object has a _ref and it is an instance - * @param {object} obj - * @returns {boolean} - */ -function filterBaseInstanceReferences(obj) { - return _.isString(obj[referenceProperty]) && obj[referenceProperty].indexOf('/instances/') !== -1; -} - -/** - * determine if a component is a layout, by checking its schema - * @param {string} uri - * @return {Promise} - */ -function isLayout(uri) { - return getSchema(uri).then((schema) => { - return _.get(schema, '_layout', false); - }).catch(() => false); -} - /** * - * @param {string} uri - * @param {object} data - * @param {object} [locals] + * @param {String} uri + * @param {Object} data + * @param {Object} [locals] * @returns {Promise} */ function publish(uri, data, locals) { if (data && _.size(data) > 0) { - return dbOps.cascadingPut(put)(uri, data, locals) - .then(publishedData => isLayout(uri).then((definitelyALayout) => { - if (definitelyALayout) { - let obj = { uri: uri, data: publishedData, user: locals && locals.user }; - - plugins.executeHook('publishLayout', obj); - bus.publish('publishLayout', JSON.stringify(obj)); - } - return publishedData; - })); + return dbOps.cascadingPut(put)(uri, data, locals); } return get(replaceVersion(uri), locals) - .then(latestData => composer.resolveComponentReferences(latestData, locals, filterBaseInstanceReferences)) - .then(versionedData => dbOps.cascadingPut(put)(uri, versionedData, locals)) - .then(publishedData => isLayout(uri).then((definitelyALayout) => { - if (definitelyALayout) { - let obj = { uri: uri, data: publishedData, user: locals && locals.user }; - - plugins.executeHook('publishLayout', obj); - bus.publish('publishLayout', JSON.stringify(obj)); - } - return publishedData; - })); + .then(latestData => composer.resolveComponentReferences(latestData, locals, composer.filterBaseInstanceReferences)) + .then(versionedData => dbOps.cascadingPut(put)(uri, versionedData, locals)); } /** - * Delete component data. - * - * Gets old values, so we can return them when the thing is deleted - * - * @param {string} uri - * @param {object} [locals] - * @returns {Promise} - */ -function del(uri, locals) { - return get(uri).then(oldData => { - let promise, - componentModule = files.getComponentModule(getComponentName(uri)); - - if (componentModule && _.isFunction(componentModule.del)) { - promise = componentModule.del(uri, locals); - } else { - promise = db.del(uri).return(uri); - } - - return promise.return(oldData); - }); -} - -/** - * @param {string} uri - * @param {object} data - * @param {object} [locals] + * @param {String} uri + * @param {Object} data + * @param {Object} [locals] * @returns {Promise} */ function post(uri, data, locals) { uri += '/' + uid.get(); - return dbOps.cascadingPut(put)(uri, data, locals).then(function (result) { - result._ref = uri; - return result; - }); -} + return dbOps.cascadingPut(put)(uri, data, locals) + .then(result => { + result._ref = uri; -/** - * @param {string} uri - * @returns {Promise} - */ -function getSchema(uri) { - return bluebird.try(function () { - return schema.getSchema(files.getComponentPath(getComponentName(uri))); - }); + return result; + }); } // outsiders can act on components too module.exports.get = get; module.exports.put = dbOps.cascadingPut(put); // special: could lead to multiple put operations module.exports.publish = publish; -module.exports.del = del; module.exports.post = post; module.exports.cmptPut = put; - -// repeatable look-ups -module.exports.getSchema = _.memoize(getSchema); diff --git a/lib/services/components.test.js b/lib/services/components.test.js index 440bf312..0fe911b5 100644 --- a/lib/services/components.test.js +++ b/lib/services/components.test.js @@ -6,13 +6,9 @@ const _ = require('lodash'), sinon = require('sinon'), files = require('../files'), siteService = require('./sites'), - db = require('./db'), + composer = require('./composer'), models = require('./models'), - timer = require('../timer'), - bluebird = require('bluebird'), - upgrade = require('./upgrade'), - schema = require('../schema'), - plugins = require('../plugins'), + dbOps = require('./db-operations'), expect = require('chai').expect; describe(_.startCase(filename), function () { @@ -21,249 +17,91 @@ describe(_.startCase(filename), function () { beforeEach(function () { sandbox = sinon.sandbox.create(); - sandbox.stub(db); + sandbox.stub(composer, 'resolveComponentReferences'); sandbox.stub(siteService); - sandbox.stub(files); - sandbox.stub(timer); - sandbox.stub(upgrade); - sandbox.stub(schema); - sandbox.stub(plugins); + sandbox.stub(files, 'getComponentModule'); + sandbox.stub(models, 'put'); sandbox.stub(models, 'get'); - - lib.getSchema.cache = new _.memoize.Cache(); }); afterEach(function () { sandbox.restore(); }); - describe('del', function () { - const fn = lib[this.title]; - - it('deletes', function () { - models.get.returns(bluebird.resolve({})); - db.del.returns(bluebird.resolve()); - files.getComponentModule.withArgs('whatever').returns(null); - return fn('domain.com/path/_components/whatever'); - }); + describe('put', function () { + const fn = lib.cmptPut; - it('deletes using component module', function () { - models.get.returns(bluebird.resolve({})); - files.getComponentModule.returns({del: _.constant(bluebird.resolve())}); - return fn('domain.com/path/_components/whatever'); + it('will call the `models.put` service if a model is found for a component', function () { + files.getComponentModule.returns({ save: _.noop }); + fn('domain.com/_components/foo/instances/bar', {}, {}); + sinon.assert.calledOnce(models.put); }); - it('deletes using component module gives locals', function () { - const ref = 'domain.com/path/_components/whatever', - locals = {}, - delSpy = sandbox.spy(_.constant(bluebird.resolve())); + it('will not call the `models.put` service if a model is found for a component', function () { + var returnedOps; - models.get.returns(bluebird.resolve({})); - files.getComponentModule.returns({del: delSpy}); - return fn(ref, locals).then(function () { - sinon.assert.called(files.getComponentModule); - sinon.assert.calledWith(delSpy, ref, locals); - }); + files.getComponentModule.returns({}); + returnedOps = fn('domain.com/_components/foo/instances/bar', {}, {}); + sinon.assert.notCalled(models.put); + expect(returnedOps).to.eql([{ type: 'put', key: 'domain.com/_components/foo/instances/bar', value: '{}' }]); }); }); - describe('put', function () { + describe('get', function () { const fn = lib[this.title]; - it('throw exception if model does not return object', function (done) { - const ref = 'a', - data = {}, - putSpy = sinon.stub(); - - putSpy.returns('abc'); - files.getComponentModule.returns({save: putSpy}); - db.batch.returns(bluebird.resolve()); - - fn(ref, data).then(done).catch(function (error) { - expect(error.message).to.equal('Unable to save a: Data from model.save must be an object!'); - done(); - }); - }); - - it('puts', function () { - const ref = 'a', - data = {}; - - db.batch.returns(bluebird.resolve()); - - return fn(ref, data).then(function () { - expect(db.batch.getCall(0).args[0]).to.deep.contain.members([{ key: 'a', type: 'put', value: '{}' }]); - }); - }); - - it('returns original object if successful', function () { - const ref = 'a', - data = {}; - - db.batch.returns(bluebird.resolve()); - - return fn(ref, data).then(function (result) { - expect(result).to.deep.equal({}); - }); - }); - - it('cascades', function () { - const ref = 'a', - data = {a: 'b', c: {_ref:'d', e: 'f'}}; - - db.batch.returns(bluebird.resolve()); - - return fn(ref, data).then(function () { - const ops = db.batch.getCall(0).args[0]; - - expect(ops).to.deep.contain.members([ - { key: 'd', type: 'put', value: JSON.stringify({e: 'f'}) }, - { key: 'a', type: 'put', value: JSON.stringify({a: 'b', c: { _ref: 'd'}}) } - ]); - }); - }); - - it('cascades with component models gives locals', function () { - const ref = 'a', - locals = {}, - data = {a: 'b', c: {_ref:'d', e: 'f'}}, - putSpy = sinon.stub(); - - files.getComponentModule.returns({save: putSpy}); - db.batch.returns(bluebird.resolve()); - putSpy.withArgs('a', sinon.match.object).returns({h: 'i'}); - putSpy.withArgs('d', sinon.match.object).returns({k: 'l'}); - - return fn(ref, data, locals).then(function () { - sinon.assert.called(files.getComponentModule); - sinon.assert.calledWith(putSpy.firstCall, 'd', { e: 'f' }, locals); - sinon.assert.calledWith(putSpy.secondCall, 'a', { a: 'b', c: { _ref: 'd' } }, locals); - }); - }); - - it('returns basic root object if successful even if cascading', function () { - const ref = 'a', - data = {a: 'b', c: {_ref:'d', e: 'f'}}; - - db.batch.returns(bluebird.resolve([])); - - return fn(ref, data).then(function (result) { - expect(result).to.deep.equal({ a: 'b', c: { _ref: 'd' } }); - }); + it('will call the `models.get` service', function () { + files.getComponentModule.returns({ render: _.noop }); + fn('domain.com/_components/foo/instances/bar', {}); + sinon.assert.calledOnce(models.get); }); - it('puts with default behavior if componenthooks is explicitly false', function () { - const ref = 'a', - data = {}, - putSpy = sinon.stub().returns(Promise.resolve({})); - - db.batch.returns(bluebird.resolve()); - files.getComponentModule.returns({save: putSpy}); - return fn(ref, data, { componenthooks: 'false' }).then(() => { - expect(db.batch.getCall(0).args[0]).to.deep.contain.members([{ key: 'a', type: 'put', value: '{}' }]); - }); + it('will call the `models.get` with the render model if one exists', function () { + files.getComponentModule.returns({ render: _.noop }); + fn('domain.com/_components/foo/instances/bar', { extension: 'foo' }); + sinon.assert.calledOnce(models.get); }); }); - describe('publish', function () { + describe('post', function () { const fn = lib[this.title]; - it('publishes if given data', function () { - const uri = 'some uri', - data = {a: 'b'}; - - db.batch.returns(bluebird.resolve()); - schema.getSchema.returns(Promise.reject()); - - return fn(uri, data).then(function (result) { - expect(result).to.deep.equal(data); - sinon.assert.calledWith(db.batch, [{ type: 'put', key: 'some uri', value: JSON.stringify(data) }]); - }); - }); - - it('publishes latest composed data if not given data', function () { - const uri = 'some uri', - deepUri = 'd/_components/e/instances/f', - data = {a: 'b', c: {_ref: deepUri}}, - deepData = {g: 'h'}; - - db.batch.returns(bluebird.resolve()); - models.get.onFirstCall().returns(bluebird.resolve(data)); - models.get.onSecondCall().returns(bluebird.resolve(deepData)); - schema.getSchema.returns(Promise.resolve({})); - - return fn(uri).then(function (result) { - expect(result).to.deep.equal(data); - sinon.assert.calledWith(db.batch, [ - { key: 'd/_components/e/instances/f', type: 'put', value: '{"g":"h"}' }, - { key: 'some uri', type: 'put', value: '{"a":"b","c":{"_ref":"d/_components/e/instances/f"}}'} - ]); - }); - }); - - it('publishes latest composed data if not given data, but not base components', function () { - const uri = 'some uri', - deepUri = 'd/_components/e', - data = {a: 'b', c: {_ref: deepUri}}, - deepData = {g: 'h'}; - - db.batch.returns(bluebird.resolve()); - models.get.onFirstCall().returns(bluebird.resolve(data)); - models.get.onSecondCall().returns(bluebird.resolve(deepData)); - schema.getSchema.returns(Promise.resolve({ _layout: false })); - - return fn(uri).then(function (result) { - expect(result).to.deep.equal(data); - sinon.assert.calledWith(db.batch, [ - { key: 'some uri', type: 'put', value: '{"a":"b","c":{"_ref":"d/_components/e"}}'} - ]); - }); - }); - - it('executes plugin hook if publishing a layout', function () { - const uri = 'some uri', - data = {a: 'b'}; - - db.batch.returns(bluebird.resolve()); - schema.getSchema.returns(Promise.resolve({ _layout: true })); + it('it will create a cascading put for the component', function () { + const ref = 'domain.com/_components/foo/instances'; - return fn(uri, data, { user: { name: 'someone' }}).then(function () { - expect(plugins.executeHook.called).to.equal(true); - expect(plugins.executeHook.getCall(0).args[0]).to.equal('publishLayout'); - }); + sandbox.stub(dbOps, 'cascadingPut').returns(() => Promise.resolve({})); + return fn(ref, {}, 'locals') + .then(result => { + sinon.assert.calledOnce(dbOps.cascadingPut); + expect(result._ref).to.match(/domain.com\/_components\/foo\/instances/); + }); }); + }); - it('executes plugin hook if publishing a layout without data', function () { - const uri = 'some uri', - deepUri = 'd/_components/e/instances/f', - data = {a: 'b', c: {_ref: deepUri}}, - deepData = {g: 'h'}; + describe('publish', function () { + const fn = lib[this.title], + ref = 'domain.com/_components/foo/instances/bar'; - db.batch.returns(bluebird.resolve()); - models.get.onFirstCall().returns(bluebird.resolve(data)); - models.get.onSecondCall().returns(bluebird.resolve(deepData)); - schema.getSchema.returns(Promise.resolve({ _layout: true })); + it('calls get, compose and saves data when publishing with no data', function () { + composer.resolveComponentReferences.returns(Promise.resolve()); + sandbox.stub(dbOps, 'cascadingPut').returns(() => Promise.resolve({})); + models.get.returns(Promise.resolve()); - return fn(uri, null, { user: { name: 'someone' }}).then(function () { - expect(plugins.executeHook.called).to.equal(true); - expect(plugins.executeHook.getCall(0).args[0]).to.equal('publishLayout'); - }); + return fn(ref, undefined, {}) + .then(() => { + sinon.assert.calledOnce(models.get); + sinon.assert.calledOnce(composer.resolveComponentReferences); + sinon.assert.calledOnce(dbOps.cascadingPut); + }); }); - }); - describe('get', function () { - const fn = lib[this.title]; + it('just puts the data to the db when it is defined', function () { + sandbox.stub(dbOps, 'cascadingPut').returns(() => Promise.resolve({})); - it('will call the `models.get` service', function () { - files.getComponentModule.returns({ render: _.noop }); - fn('domain.com/_components/foo/instances/bar', {}); - sinon.assert.calledOnce(models.get); - }); - - it('will call the `models.get` with the render model if one exists', function () { - files.getComponentModule.returns({ render: _.noop }); - fn('domain.com/_components/foo/instances/bar', { extension: 'foo' }); - sinon.assert.calledOnce(models.get); + return fn(ref, { foo: 'bar' }, {}) + .then(() => { + sinon.assert.calledOnce(dbOps.cascadingPut); + }); }); }); }); diff --git a/lib/services/composer.js b/lib/services/composer.js index e152e1c8..20a811be 100644 --- a/lib/services/composer.js +++ b/lib/services/composer.js @@ -19,12 +19,11 @@ var log = require('./logger').setup({ file: __filename }); function resolveComponentReferences(data, locals, filter = referenceProperty) { const referenceObjects = references.listDeepObjects(data, filter); - return bluebird.all(referenceObjects).each(function (referenceObject) { - + return bluebird.all(referenceObjects).each(referenceObject => { return components.get(referenceObject[referenceProperty], locals) - .then(function (obj) { + .then(obj => { // the thing we got back might have its own references - return resolveComponentReferences(obj, locals, filter).finally(function () { + return resolveComponentReferences(obj, locals, filter).finally(() => { _.assign(referenceObject, _.omit(obj, referenceProperty)); }).catch(function (error) { const logObj = { @@ -60,9 +59,18 @@ function composePage(pageData, locals) { .then(fullData => resolveComponentReferences(fullData, locals)); } +/** + * True if object has a _ref and it is an instance + * @param {Object} obj + * @returns {Boolean} + */ +function filterBaseInstanceReferences(obj) { + return _.isString(obj[referenceProperty]) && obj[referenceProperty].indexOf('/instances/') !== -1; +} module.exports.resolveComponentReferences = resolveComponentReferences; module.exports.composePage = composePage; +module.exports.filterBaseInstanceReferences = filterBaseInstanceReferences; // For testing module.exports.setLog = (fakeLogger) => log = fakeLogger; diff --git a/lib/services/composer.test.js b/lib/services/composer.test.js index e1b85aa4..8c0866d9 100644 --- a/lib/services/composer.test.js +++ b/lib/services/composer.test.js @@ -189,4 +189,15 @@ describe(_.startCase(filename), function () { }); }); + describe('filterBaseInstanceReferences', function () { + const fn = lib[this.title]; + + it('finds a component reference', function () { + expect(fn({ _ref: 'domain.com/_components/foo/instances/bar' })).to.be.true; + }); + + it('ignores a regular component data', function () { + expect(fn({ bar: 'foo' })).to.be.false; + }); + }); }); diff --git a/lib/services/db-operations.js b/lib/services/db-operations.js index 6263f7fd..055778f8 100644 --- a/lib/services/db-operations.js +++ b/lib/services/db-operations.js @@ -5,6 +5,7 @@ const _ = require('lodash'), bluebird = require('bluebird'), control = require('../control'), { replaceVersion } = require('clayutils'), + bus = require('./bus'), referenceProperty = '_ref'; var db = require('./db'); @@ -124,6 +125,7 @@ function cascadingPut(putFn) { // return ops if successful return db.batch(ops) + .then(() => bus.publish('save', JSON.stringify(ops))) .then(() => { // return the value of the last batch operation (the root object) if successful const rootOp = _.last(ops); @@ -199,3 +201,6 @@ function putDefaultBehavior(uri, data) { module.exports.cascadingPut = cascadingPut; module.exports.getPutOperations = getPutOperations; module.exports.putDefaultBehavior = putDefaultBehavior; + +// For testing +module.exports.setDb = mock => db = mock; diff --git a/lib/services/db-operations.test.js b/lib/services/db-operations.test.js index 02b9d286..634e55c5 100644 --- a/lib/services/db-operations.test.js +++ b/lib/services/db-operations.test.js @@ -4,16 +4,17 @@ const _ = require('lodash'), filename = __filename.split('/').pop().split('.').shift(), lib = require('./' + filename), sinon = require('sinon'), - db = require('./db'), + storage = require('../../test/fixtures/mocks/storage'), expect = require('chai').expect; describe(_.startCase(filename), function () { - let sandbox; + let sandbox, db; beforeEach(function () { sandbox = sinon.sandbox.create(); - sandbox.stub(db, 'batch'); + db = storage(); + lib.setDb(db); }); afterEach(function () { diff --git a/lib/services/db.js b/lib/services/db.js index 0c3d209c..55c209c4 100644 --- a/lib/services/db.js +++ b/lib/services/db.js @@ -1,22 +1,9 @@ -/** - * Mostly just converts callbacks to Promises - * - * @module - */ - 'use strict'; -// for now, use memory. - const _ = require('lodash'), jsonTransform = require('./../streams/json-transform'), - Eventify = require('eventify'), - validation = require('../validation'), promiseDefer = require('../utils/defer'), - plugins = require('../plugins'), - bus = require('./bus'), - publishedVersionSuffix = '@published'; -let db = require('levelup')('whatever', { db: require('memdown') }); + { replaceVersion } = require('clayutils'); /** * Use ES6 promises @@ -46,47 +33,12 @@ function pipeToPromise(pipe) { const d = defer(); let str = ''; - pipe.on('data', function (data) { str += data; }) + pipe.on('data', data => str += data) .on('error', d.reject) - .on('end', function () { d.resolve(str); }); + .on('end', () => d.resolve(str)); return d.promise; } -/** - * @param {string} key - * @param {string} value - * @returns {Promise} - */ -function put(key, value) { - validation.assertValidValue('put', value); - const deferred = defer(); - - db.put(key, value, deferred.apply); - return deferred.promise; -} - -/** - * @param {string} key - * @returns {Promise} - */ -function get(key) { - const deferred = defer(); - - db.get(key, deferred.apply); - return deferred.promise; -} - -/** - * @param {string} key - * @returns {Promise} - */ -function del(key) { - const deferred = defer(); - - db.del(key, deferred.apply); - return deferred.promise; -} - /** * Get a read stream of all the keys. * @@ -101,8 +53,8 @@ function del(key) { * @returns {ReadStream} */ /* eslint-disable complexity */ -function list(options) { - options = _.defaults(options || {}, { +function list(options = {}) { + options = _.defaults(options, { limit: -1, keys: true, values: true, @@ -128,7 +80,7 @@ function list(options) { transformOptions.isArray = true; } - readStream = db.createReadStream(options); + readStream = module.exports.createReadStream(options); if (_.isFunction(options.transforms)) { options.transforms = options.transforms(); @@ -149,146 +101,30 @@ function list(options) { } /** - * @param {Array} ops - * @param {object} [options] - * @returns {Promise} - */ -function batch(ops, options) { - validation.assertValidBatchOps(ops); - const deferred = defer(); - - db.batch(ops, options || {}, deferred.apply); - - return deferred.promise; -} - -/** - * Clear all records from the DB. (useful for unit testing) + * Get latest data of uri@latest + * @param {string} uri * @returns {Promise} */ -function clear() { - const errors = [], - ops = [], - deferred = defer(); - - db.createReadStream({ - keys:true, - fillCache: false, - limit: -1 - }).on('data', function (data) { - ops.push({ type: 'del', key: data.key}); - }).on('error', function (error) { - errors.push(error); - }).on('end', function () { - if (errors.length) { - deferred.apply(_.head(errors)); - } else { - db.batch(ops, deferred.apply); - } - }); - - return deferred.promise; -} - -/** - * - * @param {object} hookOps - * @param {Array} hookOps.put - * @param {Array} hookOps.del - * @param {object} op - * @param {string} op.type - * @param {string} op.key - * @param {*} op.value - * @returns {object} - */ -function addToHookOps(hookOps, op) { - hookOps[op.type].push(op); - return hookOps; -} - -/** - * - * @param {string} type - * @param {string} key - * @param {*} value - * @returns {[{}]} - */ -function singleOpToBatch(type, key, value) { - return [{type: type, key: key, value: value}]; -} - -/** - * reduce database operations to key-value arrays sorted by operation type - * defines what the plugin hooks expect to receive - * @param {string} method - * @param {Array} args args from put, del, or batch - * @returns {{put: [{key: value}], del: [{key: value}]}} - */ -function dbOpsToHookOps(method, args) { - var hookOps = { - put: [], - del: [] - }, - batchOps = method === 'batch' ? args[0] : singleOpToBatch(method, args[0], args[1]); - - return _.reduce(batchOps, addToHookOps, hookOps); +function getLatestData(uri) { + return module.exports.get(replaceVersion(uri)); } /** + * Register the storage module by assigning its + * methods to the exports of the internal db module * - * @param {string} method - * @param {Array} args + * @param {Object} storage */ -function triggerPlugins(method, args) { - const hookOps = dbOpsToHookOps(method, args), - // assumes page publish batches always have page put as the last op - lastPutKey = _.get(_.last(hookOps.put), 'key'), - isPublishedPageBatch = method === 'batch' && hookOps.put.length > 1 && _.includes(lastPutKey, '/_pages/') && _.endsWith(lastPutKey, publishedVersionSuffix); - - if (hookOps.put.length) { - if (isPublishedPageBatch) { - plugins.executeHook('publish', { - uri: lastPutKey, - ops: hookOps.put - }); - } else { - plugins.executeHook('save', hookOps.put); - } - - bus.publish('save', JSON.stringify(hookOps.put)); +function registerStorage(storage) { + for (let action in storage) { + module.exports[action] = storage[action]; } - if (hookOps.del.length) { - plugins.executeHook('delete', hookOps.del); - bus.publish('delete', JSON.stringify(hookOps.del)); - } -} - -/** - * notifies outside listeners through eventify and amphora plugins if operation is successful - * @param {string} method - */ -function exposeMethodToOutsideListeners(method) { - module.exports[method] = _.wrap(module.exports[method], function (fn) { - const args = _.slice(arguments, 1); - - return fn.apply(module.exports, args).then(function (result) { - module.exports.trigger.apply(module.exports, [method].concat(args)); // Eventify - triggerPlugins(method, args); - return result; - }); - }); } -module.exports.get = get; -module.exports.put = put; -module.exports.del = del; module.exports.list = list; -module.exports.batch = batch; -module.exports.clear = clear; -module.exports.getDB = function () { return db; }; -module.exports.setDB = function (value) { db = value; }; +module.exports.getLatestData = getLatestData; module.exports.pipeToPromise = pipeToPromise; +module.exports.registerStorage = registerStorage; -Eventify.enable(module.exports); - -_.each(['put', 'batch', 'del'], exposeMethodToOutsideListeners); +// For testing +module.exports.defer = defer; diff --git a/lib/services/db.test.js b/lib/services/db.test.js index 3d6202c8..63e66516 100644 --- a/lib/services/db.test.js +++ b/lib/services/db.test.js @@ -3,237 +3,69 @@ const _ = require('lodash'), filename = __filename.split('/').pop().split('.').shift(), lib = require('./' + filename), - plugins = require('../plugins'), bluebird = require('bluebird'), expect = require('chai').expect, - sinon = require('sinon'); + sinon = require('sinon'), + storage = require('../../test/fixtures/mocks/storage'); describe(_.startCase(filename), function () { - let sandbox; + let sandbox, db; beforeEach(function () { sandbox = sinon.sandbox.create(); - sandbox.stub(plugins, 'executeHook'); - return lib.clear(); + db = storage(); + lib.registerStorage(db); }); afterEach(function () { sandbox.restore(); - lib.off(); + return db.clearMem(); }); after(function () { - return lib.clear(); - }); - - it('can put and get strings', function () { - return lib.put('1', '2').then(function () { - return lib.get('1'); - }).done(function (result) { - expect(result).to.equal('2'); - }); - }); - - it('can put and get event', function () { - const spy = sandbox.spy(); - - lib.on('put', spy); - - return lib.put('1', '2').then(function () { - sinon.assert.calledOnce(spy); - }); - }); - - it('can put and del strings', function () { - return lib.put('1', '2').then(function () { - return lib.del('1'); - }).done(function (result) { - expect(result).to.equal(undefined); - }); - }); - - it('cannot get deleted strings', function (done) { - lib.put('1', '2').then(function () { - return lib.del('1'); - }).then(function () { - return lib.get('1'); - }).done(done, function (err) { - expect(err.name).to.equal('NotFoundError'); - done(); - }); - }); - - it('can batch and get event', function () { - const spy = sandbox.spy(); - - lib.on('batch', spy); - - return lib.batch([{type: 'put', key: 'a', value: 'b'}]).then(function () { - sinon.assert.calledOnce(spy); - }); - }); - - describe('put', function () { - const fn = lib[this.title]; - - it('executes save plugin hook', function () { - return fn('key','val').then(() => { - expect(plugins.executeHook.firstCall.args).to.deep.equal([ - 'save', [{type: 'put', key: 'key', value: 'val'}] - ]); - }); - }); - it('executes only save plugin hook for @published', function () { - return fn('/_pages/key@published','val').then(() => { - expect(plugins.executeHook.firstCall.args).to.deep.equal([ - 'save', [{type: 'put', key: '/_pages/key@published', value: 'val'}] - ]); - expect(plugins.executeHook.callCount).to.equal(1); - }); - }); - }); - - describe('batch', function () { - const fn = lib[this.title]; - - it('executes save plugin hook', function () { - const ops = [{type: 'put', key: 'key', value: 'val'}]; - - return fn(ops).then(() => { - expect(plugins.executeHook.firstCall.args).to.deep.equal([ - 'save', ops - ]); - }); - }); - it('executes only save plugin hook if not a page publish batch', function () { - const ops = [{type: 'put', key: 'key@published', value: 'val'}]; - - return fn(ops).then(() => { - expect(plugins.executeHook.firstCall.args).to.deep.equal([ - 'save', ops - ]); - expect(plugins.executeHook.callCount).to.equal(1); - }); - }); - it('executes only save plugin hook if only one put operation', function () { - const ops = [ - {type: 'put', key: '/_pages/key@published', value: 'val'} - ]; - - return fn(ops).then(() => { - expect(plugins.executeHook.firstCall.args).to.deep.equal([ - 'save', ops - ]); - expect(plugins.executeHook.callCount).to.equal(1); - }); - }); - it('executes publish plugin hooks for page publish batch', function () { - const ops = [ - {type: 'put', key: 'key@published', value: 'val'}, - {type: 'put', key: '/_pages/key@published', value: 'val'} - ]; - - return fn(ops).then(() => { - expect(plugins.executeHook.firstCall.args).to.deep.equal([ - 'publish', { - uri: '/_pages/key@published', - ops: ops - } - ]); - }); - }); - }); - - describe('del', function () { - const fn = lib[this.title]; - - it('executes delete plugin hook', function () { - return fn('key','val').then(() => { - expect(plugins.executeHook.firstCall.args).to.deep.equal([ - 'delete', [{type: 'del', key: 'key', value: 'val'}] - ]); - }); - }); - }); - - describe('clear', function () { - const fn = lib[this.title]; - - it('handles db errors as promise', function (done) { - let on, mockOn; - - // fake pipe; - on = _.noop; - on.on = on; - sandbox.stub(lib.getDB(), 'createReadStream').callsFake(_.constant(on)); - mockOn = sandbox.mock(on); - mockOn.expects('on').withArgs('data', sinon.match.func).yields('some data').exactly(1).returns(on); - mockOn.expects('on').withArgs('error', sinon.match.func).yields('whatever').exactly(1).returns(on); - mockOn.expects('on').withArgs('end', sinon.match.func).yields().exactly(1).returns(on); - - fn().done(done, function () { - sandbox.verify(); - done(); - }); - }); - - it('deletes all records', function () { - return lib.put('1', '2').then(function () { - return fn(); - }).then(function () { - return bluebird.settle([lib.get('1'), lib.get('2')]); - }).spread(function (get1, get2) { - - expect(get1.isRejected()).to.equal(true); - expect(get2.isRejected()).to.equal(true); - - }); - }); + return db.clearMem(); }); describe('list', function () { const fn = lib[this.title]; it('default behaviour', function () { - return bluebird.join(lib.put('1', '2'), lib.put('3', '4'), lib.put('5', '6')) - .then(function () { - return lib.pipeToPromise(fn()); - }).then(function (str) { + return bluebird.join(db.writeToInMem('1', '2'), db.writeToInMem('3', '4'), db.writeToInMem('5', '6')) + .then(() => lib.pipeToPromise(fn())) + .then(str => { expect(str).to.equal('{"1":"2","3":"4","5":"6"}'); }); }); it('can get keys-only in array structure', function () { - return bluebird.join(lib.put('1', '2'), lib.put('3', '4'), lib.put('5', '6')) - .then(function () { - return lib.pipeToPromise(fn({keys: true, values: false})); - }).then(function (str) { + return bluebird.join(db.writeToInMem('1', '2'), db.writeToInMem('3', '4'), db.writeToInMem('5', '6')) + .then(() => lib.pipeToPromise(fn({keys: true, values: false}))) + .then(str => { expect(str).to.equal('["1","3","5"]'); }); }); it('can get values-only in array structure', function () { - return bluebird.join(lib.put('1', '2'), lib.put('3', '4'), lib.put('5', '6')) - .then(function () { - return lib.pipeToPromise(fn({keys: false, values: true})); - }).then(function (str) { + return bluebird.join(db.writeToInMem('1', '2'), db.writeToInMem('3', '4'), db.writeToInMem('5', '6')) + .then(() => lib.pipeToPromise(fn({keys: false, values: true}))) + .then(str => { expect(str).to.equal('["2","4","6"]'); }); }); it('can get key-value in object structure in array', function () { - return bluebird.join(lib.put('1', '2'), lib.put('3', '4'), lib.put('5', '6')) - .then(function () { - return lib.pipeToPromise(fn({isArray: true})); - }).then(function (str) { + return bluebird.join(db.writeToInMem('1', '2'), db.writeToInMem('3', '4'), db.writeToInMem('5', '6')) + .then(() => lib.pipeToPromise(fn({isArray: true}))) + .then(str => { expect(str).to.equal('[{"key":"1","value":"2"},{"key":"3","value":"4"},{"key":"5","value":"6"}]'); }); }); it('can return empty data safely for arrays', function () { - return lib.pipeToPromise(fn({isArray: true})).then(function (str) { - expect(str).to.equal('[]'); - }); + return lib.pipeToPromise(fn({isArray: true})) + .then(str => { + expect(str).to.equal('[]'); + }); }); it('can return empty data safely for objects', function () { @@ -247,8 +79,8 @@ describe(_.startCase(filename), function () { onEnd = () => done(), onError = done; - lib.put('1', '2') - .then(()=>{ + db.writeToInMem('1', '2') + .then(() => { const pipe = fn({ json: false }); @@ -260,13 +92,16 @@ describe(_.startCase(filename), function () { }); }); - describe('setDB', function () { + describe('getLatestData', function () { const fn = lib[this.title]; - it('sets', function () { - expect(function () { - fn(lib.getDB()); - }).to.not.throw(); + it('grabs the latest data', function () { + lib.get.returns(Promise.resolve()); + + return fn('domain.com/_components/foo/instances/bar@published') + .then(() => { + sinon.assert.calledWith(lib.get, 'domain.com/_components/foo/instances/bar'); + }); }); }); }); diff --git a/lib/services/layouts.js b/lib/services/layouts.js new file mode 100644 index 00000000..368e2bbc --- /dev/null +++ b/lib/services/layouts.js @@ -0,0 +1,113 @@ +'use strict'; + +const _ = require('lodash'), + composer = require('./composer'), + dbOps = require('./db-operations'), + uid = require('../uid'), + files = require('../files'), + models = require('./models'), + meta = require('./metadata'), + { getLayoutName, replaceVersion } = require('clayutils'), + bus = require('./bus'); + +/** + * Get layout data from the db and pass it + * through any model/renderer model/upgrade + * + * @param {string} uri + * @param {object} [locals] + * @returns {Promise} + */ +function get(uri, locals) { + const name = getLayoutName(uri), + model = name && files.getLayoutModules(name), + callHooks = _.get(locals, 'hooks') !== 'false', + reqExtension = _.get(locals, 'extension'), + renderModel = reqExtension && files.getLayoutModules(name, reqExtension), + executeRender = model && _.isFunction(model.render) && callHooks; + + return models.get(model, renderModel, executeRender, uri, locals); +} + +/** + * Run the data of a component or layout through its + * model (or not) when being saved + * + * @param {string} uri + * @param {string} data + * @param {object} [locals] + * @returns {Promise} + */ +function put(uri, data, locals) { + var model = files.getLayoutModules(getLayoutName(uri)), + callHooks = _.get(locals, 'hooks') !== 'false', + result; + + if (model && _.isFunction(model.save) && callHooks) { + result = models.put(model, uri, data, locals); + } else { + result = dbOps.putDefaultBehavior(uri, data); + } + + return result; +} + +/** + * + * @param {string} uri + * @param {object} data + * @param {object} [locals] + * @returns {Promise} + */ +function publish(uri, data, locals) { + const user = locals && locals.user; + + if (data && _.size(data) > 0) { + return dbOps.cascadingPut(put)(uri, data, locals) + .then(data => meta.publishLayout(uri, user) + .then(() => { + bus.publish('publishLayout', JSON.stringify({ uri, data, user })); + return data; + })); + } + + return get(replaceVersion(uri), locals) + .then(latestData => composer.resolveComponentReferences(latestData, locals, composer.filterBaseInstanceReferences)) + .then(versionedData => dbOps.cascadingPut(put)(uri, versionedData, locals)) + .then(data => meta.publishLayout(uri, user).then(() => { + bus.publish('publishLayout', JSON.stringify({ uri, data, user })); + return data; + })); +} + +/** + * @param {string} uri + * @param {object} data + * @param {object} [locals] + * @returns {Promise} + */ +function post(uri, data, locals) { + const user = locals && locals.user; + + uri += '/' + uid.get(); + + return dbOps.cascadingPut(put)(uri, data, locals) + .then(result => { + result._ref = uri; + + return meta.createLayout(uri, user) + .then(() => { + bus.publish('createLayout', JSON.stringify({ uri, data, user })); + return result; + }); + }); +} + +// outsiders can act on components too +module.exports.get = get; +module.exports.put = dbOps.cascadingPut(put); +module.exports.publish = publish; +module.exports.post = post; + +// For testing +module.exports.testPut = put; diff --git a/lib/services/layouts.test.js b/lib/services/layouts.test.js new file mode 100644 index 00000000..bef77c74 --- /dev/null +++ b/lib/services/layouts.test.js @@ -0,0 +1,120 @@ +'use strict'; + +const _ = require('lodash'), + filename = __filename.split('/').pop().split('.').shift(), + lib = require('./' + filename), + sinon = require('sinon'), + files = require('../files'), + siteService = require('./sites'), + composer = require('./composer'), + models = require('./models'), + dbOps = require('./db-operations'), + meta = require('./metadata'), + bus = require('./bus'), + expect = require('chai').expect; + +describe(_.startCase(filename), function () { + let sandbox; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + + sandbox.stub(composer, 'resolveComponentReferences'); + sandbox.stub(siteService); + sandbox.stub(files, 'getLayoutModules'); + sandbox.stub(models, 'put'); + sandbox.stub(models, 'get'); + sandbox.stub(meta, 'createLayout'); + sandbox.stub(meta, 'publishLayout'); + sandbox.stub(bus, 'publish'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('put', function () { + const fn = lib.testPut; + + it('will call the `models.put` service if a model is found for a layout', function () { + files.getLayoutModules.returns({ save: _.noop }); + fn('domain.com/_layouts/foo/instances/bar', {}, {}); + sinon.assert.calledOnce(models.put); + }); + + it('will not call the `models.put` service if a model is found for a layout', function () { + var returnedOps; + + files.getLayoutModules.returns({}); + returnedOps = fn('domain.com/_layouts/foo/instances/bar', {}, {}); + sinon.assert.notCalled(models.put); + expect(returnedOps).to.eql([{ type: 'put', key: 'domain.com/_layouts/foo/instances/bar', value: '{}' }]); + }); + }); + + describe('get', function () { + const fn = lib[this.title]; + + it('will call the `models.get` service', function () { + files.getLayoutModules.returns({ render: _.noop }); + fn('domain.com/_layouts/foo/instances/bar', {}); + sinon.assert.calledOnce(models.get); + }); + + it('will call the `models.get` with the render model if one exists', function () { + files.getLayoutModules.returns({ render: _.noop }); + fn('domain.com/_layouts/foo/instances/bar', { extension: 'foo' }); + sinon.assert.calledOnce(models.get); + }); + }); + + describe('post', function () { + const fn = lib[this.title]; + + it('it will create a cascading put for the component', function () { + const ref = 'domain.com/_layouts/foo/instances'; + + sandbox.stub(dbOps, 'cascadingPut').returns(() => Promise.resolve({})); + meta.createLayout.returns(Promise.resolve()); + return fn(ref, {}, 'locals') + .then(result => { + sinon.assert.calledOnce(dbOps.cascadingPut); + sinon.assert.calledOnce(bus.publish); + sinon.assert.calledOnce(meta.createLayout); + expect(result._ref).to.match(/domain.com\/_layouts\/foo\/instances/); + }); + }); + }); + + describe('publish', function () { + const fn = lib[this.title], + ref = 'domain.com/_layouts/foo/instances/bar'; + + it('calls get, compose and saves data when publishing with no data', function () { + composer.resolveComponentReferences.returns(Promise.resolve()); + sandbox.stub(dbOps, 'cascadingPut').returns(() => Promise.resolve({})); + models.get.returns(Promise.resolve()); + meta.publishLayout.returns(Promise.resolve()); + + return fn(ref, undefined, {}) + .then(() => { + sinon.assert.calledOnce(models.get); + sinon.assert.calledOnce(composer.resolveComponentReferences); + sinon.assert.calledOnce(dbOps.cascadingPut); + sinon.assert.calledOnce(meta.publishLayout); + sinon.assert.calledOnce(bus.publish); + }); + }); + + it('just puts the data to the db when it is defined', function () { + sandbox.stub(dbOps, 'cascadingPut').returns(() => Promise.resolve({})); + meta.publishLayout.returns(Promise.resolve()); + return fn(ref, { foo: 'bar' }, {}) + .then(() => { + sinon.assert.calledOnce(dbOps.cascadingPut); + sinon.assert.calledOnce(meta.publishLayout); + sinon.assert.calledOnce(bus.publish); + }); + }); + }); +}); diff --git a/lib/services/metadata.js b/lib/services/metadata.js new file mode 100644 index 00000000..fb47ba3e --- /dev/null +++ b/lib/services/metadata.js @@ -0,0 +1,249 @@ +'use strict'; + +const _ = require('lodash'), + bus = require('./bus'), + { replaceVersion } = require('clayutils'), + sitesService = require('./sites'); +var db = require('./db'); + +/** + * Either return the user object or + * the system for a specific action + * + * @param {Object} user + * @returns {Object} + */ +function userOrRobot(user) { + if (user && _.get(user, 'username') && _.get(user, 'provider')) { + return user; + } else { + // no actual user, this was an api key + return { + username: 'robot', + provider: 'clay', + imageUrl: 'clay-avatar', // kiln will supply a clay avatar + name: 'Clay', + auth: 'admin' + }; + } +} + +/** + * On publish of a page, update the + * metadara for the page + * + * @param {String} uri + * @param {Object} publishMeta + * @param {Object|Undefined} user + * @returns {Promise} + */ +function publishPage(uri, publishMeta, user) { + const NOW = new Date().toISOString(), + update = { + published: true, + publishTime: NOW, + history: [{ action: 'publish', timestamp: NOW, users: [userOrRobot(user)] }] + }; + + return changeMetaState(uri, Object.assign(publishMeta, update)); +} + +/** + * Create the initial meta object + * for the page + * + * @param {String} ref + * @param {Object|Underfined} user + * @returns {Promise} + */ +function createPage(ref, user) { + const NOW = new Date().toISOString(), + users = [userOrRobot(user)], + meta = { + createdAt: NOW, + archived: false, + published: false, + publishTime: null, + updateTime: null, + urlHistory: [], + firstPublishTime: null, + url: '', + title: '', + authors: [], + users, + history: [{action: 'create', timestamp: NOW, users }], + siteSlug: sitesService.getSiteFromPrefix(ref.substring(0, ref.indexOf('/_pages'))).slug + }; + + return putMeta(ref, meta); +} + +function createLayout(ref, user) { + const NOW = new Date().toISOString(), + users = [userOrRobot(user)], + meta = { + createdAt: NOW, + published: false, + publishTime: null, + updateTime: null, + firstPublishTime: null, + title: '', + history: [{ action: 'create', timestamp: NOW, users }], + siteSlug: sitesService.getSiteFromPrefix(ref.substring(0, ref.indexOf('/_layouts'))).slug + }; + + return putMeta(ref, meta); +} + +/** + * Update the layouts meta object + * on publish + * + * @param {String} uri + * @param {Object} user + * @returns {Promise} + */ +function publishLayout(uri, user) { + const NOW = new Date().toISOString(), + users = [userOrRobot(user)], + update = { + published: true, + publishTime: NOW, + history: [{ action: 'publish', timestamp: NOW, users }] + }; + + return changeMetaState(uri, update); +} + +/** + * Update the page mera on unpublish + * + * @param {String} uri + * @param {Object} user + * @returns {Promise} + */ +function unpublishPage(uri, user) { + const update = { + published: false, + publishTime: null, + url: '', + history: [{ action: 'unpublish', timestamp: new Date().toISOString(), users: [userOrRobot(user)] }] + }; + + return changeMetaState(uri, update); +} + +/** + * Publish the `saveMeta` topic to + * the event bus + * + * @param {String} uri + * @returns {Function} + */ +function pubToBus(uri) { + return (data) => { + bus.publish('saveMeta', JSON.stringify({ uri, data })); + return data; + }; +} + +/** + * Given a uri and an object that is an + * update, retreive the old meta, merge + * the new and old and then put the merge + * to the db. + * + * @param {String} uri + * @param {Object} update + * @returns {Promise} + */ +function changeMetaState(uri, update) { + return getMeta(uri) + .then(old => mergeNewAndOldMeta(old, update)) + .then(updatedMeta => putMeta(uri, updatedMeta)); +} + +/** + * Given the existing meta object and an update, + * merge the properties which should never be + * overriden: + * + * - firstPublishTime + * - history + * + * @param {Object} old + * @param {Object} updated + * @returns {Object} + */ +function mergeNewAndOldMeta(old, updated) { + const merged = { + firstPublishTime: old.firstPublishTime || updated.publishTime + }; + + if (!Object.keys(old).length) { + return updated; + } + + if (old.history && updated.history) { + merged.history = old.history.concat(updated.history); + } + + return Object.assign(old, updated, merged); +} + +/** + * Retrieve the page's meta object + * + * @param {String} uri + * @returns {Promise} + */ +function getMeta(uri) { + const id = replaceVersion(uri.replace('/meta', '')); + + return db.getMeta(id) + .catch(() => ({})); +} + +/** + * Write the page's meta object to the DB + * + * @param {String} uri + * @param {Object} data + * @returns {Promise} + */ +function putMeta(uri, data) { + const id = replaceVersion(uri.replace('/meta', '')); + + return db.putMeta(id, data) + .then(pubToBus(id)); +} + +/** + * Update a subset of properties on + * a metadata object + * + * @param {String} uri + * @param {Object} data + * @returns {Promise} + */ +function patchMeta(uri, data) { + const id = replaceVersion(uri.replace('/meta', '')); + + return db.patchMeta(id, data) + .then(() => getMeta(uri)) + .then(pubToBus(id)); +} + +module.exports.getMeta = getMeta; +module.exports.putMeta = putMeta; +module.exports.patchMeta = patchMeta; +module.exports.createPage = createPage; +module.exports.createLayout = createLayout; +module.exports.publishPage = publishPage; +module.exports.publishLayout = publishLayout; +// TODO: unpublish layout? +module.exports.unpublishPage = unpublishPage; +module.exports.userOrRobot = userOrRobot; + +// For testing +module.exports.setDb = mock => db = mock; diff --git a/lib/services/metadata.test.js b/lib/services/metadata.test.js new file mode 100644 index 00000000..e5f68828 --- /dev/null +++ b/lib/services/metadata.test.js @@ -0,0 +1,158 @@ +'use strict'; + +const _ = require('lodash'), + filename = __filename.split('/').pop().split('.').shift(), + lib = require('./' + filename), + sinon = require('sinon'), + bus = require('./bus'), + expect = require('chai').expect, + siteService = require('./sites'), + storage = require('../../test/fixtures/mocks/storage'); + +describe(_.startCase(filename), function () { + let sandbox, db; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + + sandbox.stub(siteService, 'getSiteFromPrefix'); + sandbox.stub(bus, 'publish'); + db = storage(); + lib.setDb(db); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('getMeta', function () { + const fn = lib[this.title]; + + it('calls the db for the meta of a page/layout', function () { + db.getMeta.returns(Promise.resolve()); + + return fn('domain.com/_pages/id/meta') + .then(() => { + sinon.assert.calledWith(db.getMeta, 'domain.com/_pages/id'); + }); + }); + + it('returns an empty object if there is a failure', function () { + db.getMeta.returns(Promise.reject()); + + return fn('domain.com/_pages/id/meta') + .then(resp => { + expect(resp).to.eql({}); + sinon.assert.calledWith(db.getMeta, 'domain.com/_pages/id'); + }); + }); + }); + + describe('putMeta', function () { + const fn = lib[this.title]; + + it('puts to the db and pubs to the bus', function () { + db.putMeta.returns(Promise.resolve()); + + return fn('domain.com/_pages/id/meta', {}) + .then(() => { + sinon.assert.calledWith(db.putMeta, 'domain.com/_pages/id', {}); + sinon.assert.calledOnce(bus.publish); + }); + }); + }); + + describe('patchMeta', function () { + const fn = lib[this.title]; + + it('puts to the db and pubs to the bus', function () { + db.patchMeta.returns(Promise.resolve()); + db.getMeta.returns(Promise.resolve()); + + return fn('domain.com/_pages/id/meta', {}) + .then(() => { + sinon.assert.calledWith(db.patchMeta, 'domain.com/_pages/id', {}); + sinon.assert.calledWith(db.getMeta, 'domain.com/_pages/id'); + sinon.assert.calledOnce(bus.publish); + }); + }); + }); + + describe('createPage', function () { + const fn = lib[this.title]; + + it('creates page meta', function () { + db.putMeta.returns(Promise.resolve()); + siteService.getSiteFromPrefix.returns({ slug: 'foo' }); + + return fn('domain.com/_pages/id/meta', { username: 'foo', provider: 'bar' }) + .then(() => { + sinon.assert.calledOnce(db.putMeta); + sinon.assert.calledOnce(bus.publish); + }); + }); + }); + + describe('createLayout', function () { + const fn = lib[this.title]; + + it('creates layout meta', function () { + db.putMeta.returns(Promise.resolve()); + siteService.getSiteFromPrefix.returns({ slug: 'foo' }); + + return fn('domain.com/_pages/id/meta', { username: 'foo', provider: 'bar' }) + .then(() => { + sinon.assert.calledOnce(db.putMeta); + sinon.assert.calledOnce(bus.publish); + }); + }); + }); + + describe('publishPage', function () { + const fn = lib[this.title]; + + it('updates page meta', function () { + db.getMeta.returns(Promise.resolve({ published: false })); + db.putMeta.returns(Promise.resolve()); + + return fn('domain.com/_pages/id/meta', {}) + .then(() => { + sinon.assert.calledOnce(db.putMeta); + sinon.assert.calledWith(db.getMeta, 'domain.com/_pages/id'); + sinon.assert.calledOnce(bus.publish); + }); + }); + }); + + describe('publishLayout', function () { + const fn = lib[this.title]; + + it('updates layout meta', function () { + db.getMeta.returns(Promise.resolve({})); + db.putMeta.returns(Promise.resolve()); + + return fn('domain.com/_pages/id/meta', {}) + .then(() => { + sinon.assert.calledOnce(db.putMeta); + sinon.assert.calledWith(db.getMeta, 'domain.com/_pages/id'); + sinon.assert.calledOnce(bus.publish); + }); + }); + }); + + describe('unpublishPage', function () { + const fn = lib[this.title]; + + it('updates layout meta', function () { + db.getMeta.returns(Promise.resolve({ published: false, history: [{}] })); + db.putMeta.returns(Promise.resolve()); + + return fn('domain.com/_pages/id/meta', {}) + .then(() => { + sinon.assert.calledOnce(db.putMeta); + sinon.assert.calledWith(db.getMeta, 'domain.com/_pages/id'); + sinon.assert.calledOnce(bus.publish); + }); + }); + }); +}); diff --git a/lib/services/models.js b/lib/services/models.js index 91871beb..abc1cb04 100644 --- a/lib/services/models.js +++ b/lib/services/models.js @@ -41,7 +41,6 @@ function put(model, uri, data, locals) { const ms = timer.getMillisecondsSince(startTime); if (ms > timeoutLimit * 0.5) { - log('warn', `slow put ${uri} ${ms}ms`); } }).timeout(timeoutLimit, `Module PUT exceeded ${timeoutLimit}ms: ${uri}`); @@ -67,7 +66,6 @@ function get(model, renderModel, executeRender, uri, locals) { /* eslint max-par promise = bluebird.try(() => { return db.get(uri) - .then(JSON.parse) .then(upgrade.init(uri, locals)) // Run an upgrade! .then(data => model.render(uri, data, locals)); }).tap(result => { @@ -81,8 +79,9 @@ function get(model, renderModel, executeRender, uri, locals) { /* eslint max-par log('warn', `slow get ${uri} ${ms}ms`); } }).timeout(timeoutLimit, `Model GET exceeded ${timeoutLimit}ms: ${uri}`); + } else { - promise = db.get(uri).then(JSON.parse).then(upgrade.init(uri, locals)); // Run an upgrade! + promise = db.get(uri).then(upgrade.init(uri, locals)); // Run an upgrade! } if (renderModel && _.isFunction(renderModel)) { @@ -105,3 +104,4 @@ module.exports.put = put; module.exports.getTimeoutConstant = () => TIMEOUT_CONSTANT; module.exports.setTimeoutConstant = val => TIMEOUT_CONSTANT = val; module.exports.setLog = mock => log = mock; +module.exports.setDb = mock => db = mock; diff --git a/lib/services/models.test.js b/lib/services/models.test.js index 496a22d7..a035ac5f 100644 --- a/lib/services/models.test.js +++ b/lib/services/models.test.js @@ -5,23 +5,24 @@ const _ = require('lodash'), lib = require('./' + filename), sinon = require('sinon'), expect = require('chai').expect, - db = require('./db'), + storage = require('../../test/fixtures/mocks/storage'), upgrade = require('./upgrade'), timer = require('../timer'); describe(_.startCase(filename), function () { const timeoutConstant = 100; - let sandbox, fakeLog, savedTimeoutConstant; + let sandbox, db, fakeLog, savedTimeoutConstant; beforeEach(function () { sandbox = sinon.sandbox.create(); sandbox.stub(upgrade, 'init'); sandbox.stub(timer); - sandbox.stub(db, 'get'); fakeLog = sandbox.stub(); + db = storage(); lib.setLog(fakeLog); + lib.setDb(db); savedTimeoutConstant = lib.getTimeoutConstant(); lib.setTimeoutConstant(timeoutConstant); }); @@ -35,7 +36,7 @@ describe(_.startCase(filename), function () { const fn = lib[this.title]; it('skips the model if executeRenderer is not true', function () { - db.get.returns(Promise.resolve('{}')); + db.get.returns(Promise.resolve({})); upgrade.init.returns(Promise.resolve({})); return fn(_.noop, undefined, false, 'someuri', {}) @@ -59,7 +60,7 @@ describe(_.startCase(filename), function () { it('runs the renderer model if one is defined', function () { const rendererModel = sandbox.stub().returns(Promise.resolve({})); - db.get.returns(Promise.resolve('{}')); + db.get.returns(Promise.resolve({})); upgrade.init.returns(Promise.resolve({})); return fn(_.noop, rendererModel, false, 'someuri', {}) .then(() => { @@ -72,7 +73,7 @@ describe(_.startCase(filename), function () { it('runs the render function of model if one is defined', function () { const render = sandbox.stub().returns(Promise.resolve({})); - db.get.returns(Promise.resolve('{}')); + db.get.returns(Promise.resolve({})); upgrade.init.returns(Promise.resolve({})); return fn({ render }, undefined, true, 'someuri', {}) .then(() => { @@ -85,7 +86,7 @@ describe(_.startCase(filename), function () { it('errors if the render model does not return an object', function () { const render = sandbox.stub().returns(Promise.resolve(undefined)); - db.get.returns(Promise.resolve('{}')); + db.get.returns(Promise.resolve({})); upgrade.init.returns(Promise.resolve({})); return fn({ render }, undefined, true, 'someuri', {}) .catch(() => { @@ -95,21 +96,10 @@ describe(_.startCase(filename), function () { }); }); - it('errors if the upgrade/renderer model do not return an object', function () { - const render = sandbox.stub().returns(Promise.resolve({})); - - db.get.returns(Promise.resolve('{}')); - upgrade.init.returns(() => Promise.resolve('somestring')); - return fn({ render }, undefined, false, 'someuri', {}) - .catch(e => { - expect(e.message).to.match(/Client: Invalid data type/); - }); - }); - it('logs warning for slow component', function () { const render = sandbox.stub().returns(Promise.resolve({})); - db.get.returns(Promise.resolve('{}')); + db.get.returns(Promise.resolve({})); timer.getMillisecondsSince.returns(timeoutConstant * 3); upgrade.init.returns(Promise.resolve({})); diff --git a/lib/services/notifications.js b/lib/services/notifications.js index fa105d37..1bf37641 100644 --- a/lib/services/notifications.js +++ b/lib/services/notifications.js @@ -60,7 +60,7 @@ function notify(site, eventName, data) { const events = _.get(site, 'notify.webhooks'); if (events && _.isArray(events[eventName])) { - return bluebird.all(_.map(events[eventName], function (url) { return callWebhook(eventName, url, data); })); + return bluebird.all(_.map(events[eventName], url => callWebhook(eventName, url, data) )); } return bluebird.resolve(); diff --git a/lib/services/pages.js b/lib/services/pages.js index 9ec68a67..c3484a8a 100644 --- a/lib/services/pages.js +++ b/lib/services/pages.js @@ -3,41 +3,26 @@ var timeoutConstant = 4000, log = require('./logger').setup({ file: __filename - }); + }), + db = require('./db'); const _ = require('lodash'), buf = require('./buffer'), bluebird = require('bluebird'), components = require('./components'), + layouts = require('./layouts'), composer = require('./composer'), - db = require('./db'), notifications = require('./notifications'), references = require('./references'), siteService = require('./sites'), timer = require('../timer'), uid = require('../uid'), + meta = require('./metadata'), dbOps = require('./db-operations'), - schema = require('../schema'), - files = require('../files'), - { getComponentName, replaceVersion, getPrefix } = require('clayutils'), + { getComponentName, replaceVersion, getPrefix, isLayout } = require('clayutils'), publishService = require('./publish'), - plugins = require('../plugins'), bus = require('./bus'), timeoutPublishCoefficient = 5; -/** - * @param {number} value - */ -function setTimeoutConstant(value) { - timeoutConstant = value; -} - -/** - * @returns {number} - */ -function getTimeoutConstant() { - return timeoutConstant; -} - /** * @param {string} uri * @param {{}} data @@ -82,26 +67,26 @@ function renameReferenceUniquely(uri) { * @returns {Promise} */ function getPageClonePutOperations(pageData, locals) { - return bluebird.all(_.reduce(pageData, function (promises, pageValue, pageKey) { + return bluebird.all(_.reduce(pageData, (promises, pageValue, pageKey) => { if (typeof pageValue === 'string' && getComponentName(pageValue)) { // for all strings that are component references - promises.push(components.get(pageValue, locals).then(function (refData) { + promises.push(components.get(pageValue, locals) // only follow the paths of instance references. Don't clone default components - return composer.resolveComponentReferences(refData, locals, isInstanceReferenceObject); - }).then(function (resolvedData) { - // for each instance reference within resolved data - _.each(references.listDeepObjects(resolvedData, isInstanceReferenceObject), function (obj) { - obj._ref = renameReferenceUniquely(obj._ref); - }); + .then(refData => composer.resolveComponentReferences(refData, locals, isInstanceReferenceObject)) + .then(resolvedData => { + // for each instance reference within resolved data + _.each(references.listDeepObjects(resolvedData, isInstanceReferenceObject), obj => { + obj._ref = renameReferenceUniquely(obj._ref); + }); - // rename reference in pageData - const ref = renameReferenceUniquely(pageValue); + // rename reference in pageData + const ref = renameReferenceUniquely(pageValue); - pageData[pageKey] = ref; + pageData[pageKey] = ref; - // put new data using cascading PUT at place that page now points - return dbOps.getPutOperations(components.cmptPut, ref, resolvedData, locals); - })); + // put new data using cascading PUT at place that page now points + return dbOps.getPutOperations(components.cmptPut, ref, resolvedData, locals); + })); } else { // for all object-like things (i.e., objects and arrays) promises = promises.concat(getPageClonePutOperations(pageValue, locals)); @@ -113,23 +98,17 @@ function getPageClonePutOperations(pageData, locals) { /** * Applies operations, then returns last (root) op's value. * - * @param {object} site + * @param {Array} ops * @returns {Promise} */ -function applyBatch(site) { - return function (ops) { - const lastOp = _.last(ops), - lastValue = lastOp && JSON.parse(lastOp.value); - - return db.batch(ops).then(function () { - // if published, notify listeners - if (_.endsWith(lastOp.key, '@published')) { - notifications.notify(site, 'published', lastValue).catch(function (error) { - log('warn', `notification error: ${error.message}`); - }); - } - }).return(lastValue); - }; +function applyBatch(ops) { + return db.batch(ops) + .then(() => bus.publish('save', JSON.stringify(ops))) + .then(() => { + const lastOp = _.last(ops); + + return lastOp && JSON.parse(lastOp.value); + }); } /** @@ -156,22 +135,13 @@ function replacePageReferenceVersions(data, version) { }); } -/** - * Get latest data of uri@latest - * @param {string} uri - * @returns {Promise} - */ -function getLatestData(uri) { - return db.get(replaceVersion(uri)).then(JSON.parse); -} - /** * Get a list of all operations with all references converted to @published * @param {object} locals * @returns {function} */ function getRecursivePublishedPutOperations(locals) { - return function (rootComponentRef) { + return rootComponentRef => { /** * 1) Get reference (could be latest, could be other) * 2) Get all references within reference @@ -184,6 +154,14 @@ function getRecursivePublishedPutOperations(locals) { }; } +/** + * Given either locals or a string, + * return the site we're working with + * + * @param {String} prefix + * @param {Object} locals + * @returns {Object} + */ function getSite(prefix, locals) { const site = locals && locals.site; @@ -211,14 +189,14 @@ function assertNoEmptyValues(data) { * @returns {Promise} */ function getPublishData(uri, data) { - return bluebird.try(function () { + return bluebird.try(() => { if (data && _.size(data) > 0) { // if they actually gave us something, use it assertNoEmptyValues(data); return data; } // otherwise, assume they meant whatever is in @latest - return getLatestData(uri); + return db.getLatestData(uri); }); } @@ -249,36 +227,41 @@ function publish(uri, data, locals) { const startTime = process.hrtime(), prefix = getPrefix(uri), site = getSite(prefix, locals), - timeoutLimit = timeoutConstant * timeoutPublishCoefficient; - + timeoutLimit = timeoutConstant * timeoutPublishCoefficient, + user = locals && locals.user; + var publishedMeta; // We need to store some meta a little later return getPublishData(uri, data) - .then(publishService(uri, locals, site)) - .then(function (pageData) { + .then(publishService.resolvePublishUrl(uri, locals, site)) + .then(({ meta, data: pageData}) => { const published = 'published', - publicUrl = pageData.customUrl || pageData.url, dynamicPage = pageData._dynamic, componentList = references.getPageReferences(pageData); - if (!references.isUrl(publicUrl) && !dynamicPage) { + if (!references.isUrl(meta.url) && !dynamicPage) { throw new Error('Client: Page must have valid url to publish.'); } return bluebird.map(componentList, getRecursivePublishedPutOperations(locals)) .then(_.flatten) // only one level of flattening needed, because getPutOperations should have flattened its list already - .then(function (ops) { + .then(ops => { // convert the data to all @published pageData = replacePageReferenceVersions(pageData, published); // Make public unless we're dealing with a `_dynamic` page if (!dynamicPage) { - addOpToMakePublic(ops, prefix, publicUrl, uri); + addOpToMakePublic(ops, prefix, meta.url, uri); } + // Store the metadata if we're at this point + publishedMeta = meta; + // add page PUT operation last return addOp(replaceVersion(uri, published), pageData, ops); }); - }).then(applyBatch(site)).tap(function () { + }) + .then(applyBatch) + .tap(() => { const ms = timer.getMillisecondsSince(startTime); if (ms > timeoutLimit * 0.5) { @@ -286,13 +269,17 @@ function publish(uri, data, locals) { } else { log('info', `published ${replaceVersion(uri)} ${ms}ms`); } - }).timeout(timeoutLimit, `Page publish exceeded ${timeoutLimit}ms: ${uri}`) - .then(function (publishedData) { - let obj = { uri: uri, data: publishedData, user: locals && locals.user }; - - plugins.executeHook('publishPage', obj); - bus.publish('publishPage', JSON.stringify(obj)); - return publishedData; + }) + .timeout(timeoutLimit, `Page publish exceeded ${timeoutLimit}ms: ${uri}`) + .then(publishedData => { + return meta.publishPage(uri, publishedMeta, user).then(() => { + // Notify the bus + bus.publish('publishPage', JSON.stringify({ uri, data: publishedData, user})); + + notifications.notify(site, 'published', publishedData); + // Update the meta object + return publishedData; + }); }); } @@ -307,42 +294,32 @@ function create(uri, data, locals) { const layoutReference = data && data.layout, pageData = data && references.omitPageConfiguration(data), prefix = getPrefix(uri), - site = getSite(prefix, locals), - pageReference = `${prefix}/_pages/${uid.get()}`; + pageReference = `${prefix}/_pages/${uid.get()}`, + user = locals && locals.user; - if (!layoutReference) { + if (!layoutReference || !isLayout(layoutReference)) { throw new Error('Client: Data missing layout reference.'); } - return components.get(layoutReference, locals).then(function () { + return layouts.get(layoutReference, locals) // check to make sure the layout 200's + .then(() => { + return getPageClonePutOperations(pageData, locals) + .then(ops => { + pageData.layout = layoutReference; + return addOp(pageReference, pageData, ops); + }); + }) + .then(applyBatch) + .then(newPage => { + newPage._ref = pageReference; - return getPageClonePutOperations(pageData, locals).then(function (ops) { - pageData.layout = layoutReference; + return meta.createPage(newPage._ref, user) + .then(() => { + bus.publish('createPage', JSON.stringify({ uri: pageReference, data: newPage, user })); - return addOp(pageReference, pageData, ops); + return newPage; + }); }); - }).then(applyBatch(site)).then(function (newPage) { - let obj = { uri: pageReference, data: newPage, user: locals && locals.user }; - - plugins.executeHook('createPage', obj); - bus.publish('createPage', JSON.stringify(obj)); - // if successful, return new page object, but include the (optional) self reference to the new page. - newPage._ref = pageReference; - - return newPage; - }); -} - -/** - * determine if a referenced layout is actually a layout, by checking its schema - * @param {string} uri - * @return {Promise} - */ -function isLayout(uri) { - return bluebird.try(() => schema.getSchema(files.getComponentPath(getComponentName(uri)))) - .then((schema) => { - return _.get(schema, '_layout', false); - }).catch(() => false); } /** @@ -355,23 +332,25 @@ function isLayout(uri) { * @returns {Promise} */ function putLatest(uri, data, locals) { - // check the page layout for the '_layout' boolean - return isLayout(data.layout).then((layoutIsLayout) => { - if (!layoutIsLayout) { - // todo: in the next major version, throw an error to prevent saving pages that reference incorrectly configured layouts - log('warn', `layout must specify '_layout: true' in its schema: ${data.layout}`); - } + const user = locals && locals.user; - // continue saving the page normally - return getLatestData(uri) - .then(() => db.put(uri, JSON.stringify(data)).return(data)) // data already exists - .catch(() => db.put(uri, JSON.stringify(data)).then(() => { - let obj = { uri, data, user: locals && locals.user }; + // check the page for a proper layout + if (!data.layout || !isLayout(data.layout)) { + throw Error('Page must contain a `layout` property whose value is a `_layouts` instance'); + } - plugins.executeHook('createPage', obj); - bus.publish('createPage', JSON.stringify(obj)); - })).return(data); - }); + // continue saving the page normally + return db.getLatestData(uri) + .then(() => db.put(uri, JSON.stringify(data)).then(() => data)) // data already exist + .catch(() => { + return db.put(uri, JSON.stringify(data)) + .then(() => meta.createPage(uri, user)) + .then(() => { + bus.publish('createPage', JSON.stringify({ uri, data, user })); + + return data; + }); + }); } module.exports.create = create; @@ -379,8 +358,8 @@ module.exports.publish = publish; module.exports.putLatest = putLatest; module.exports.replacePageReferenceVersions = replacePageReferenceVersions; -module.exports.getTimeoutConstant = getTimeoutConstant; -module.exports.setTimeoutConstant = setTimeoutConstant; - // For testing +module.exports.setTimeoutConstant = val => timeoutConstant = val; +module.exports.getTimeoutConstant = () => timeoutConstant; module.exports.setLog = mock => log = mock; +module.exports.setDb = mock => db = mock; diff --git a/lib/services/pages.test.js b/lib/services/pages.test.js index b8fb379a..845623e1 100644 --- a/lib/services/pages.test.js +++ b/lib/services/pages.test.js @@ -3,7 +3,7 @@ const _ = require('lodash'), bluebird = require('bluebird'), components = require('./components'), - db = require('../services/db'), + layouts = require('./layouts'), expect = require('chai').expect, filename = __filename.split('/').pop().split('.').shift(), lib = require('./' + filename), @@ -11,25 +11,36 @@ const _ = require('lodash'), sinon = require('sinon'), siteService = require('./sites'), timer = require('../timer'), - plugins = require('../plugins'), - schema = require('../schema'); + meta = require('./metadata'), + schema = require('../schema'), + publishService = require('./publish'), + composer = require('./composer'), + bus = require('./bus'), + dbOps = require('./db-operations'), + storage = require('../../test/fixtures/mocks/storage'); describe(_.startCase(filename), function () { const timeoutConstant = 100; - let sandbox, + let sandbox, db, savedTimeoutConstant, fakeLog; beforeEach(function () { sandbox = sinon.sandbox.create(); fakeLog = sandbox.stub(); - sandbox.stub(db); sandbox.stub(components, 'get'); + sandbox.stub(layouts, 'get'); sandbox.stub(siteService, 'getSiteFromPrefix'); sandbox.stub(notifications, 'notify'); + sandbox.stub(dbOps); sandbox.stub(timer); - sandbox.stub(plugins); + sandbox.stub(meta); + sandbox.stub(bus); sandbox.stub(schema); + sandbox.stub(composer); + sandbox.stub(publishService, 'resolvePublishUrl'); + db = storage(); + lib.setDb(db); savedTimeoutConstant = lib.getTimeoutConstant(); lib.setTimeoutConstant(timeoutConstant); @@ -45,30 +56,45 @@ describe(_.startCase(filename), function () { describe('create', function () { const fn = lib[this.title]; + it('errors if no layout is in the data', function () { + const result = () => fn('domain.com/path/_pages', {layout: 'domain.com/path/_components/thing'}); + + expect(result).to.throw(Error); + }); + it('creates without content', function () { - components.get.returns(bluebird.resolve({})); - db.batch.returns(bluebird.resolve()); + layouts.get.returns(Promise.resolve({})); + db.batch.returns(Promise.resolve()); siteService.getSiteFromPrefix.returns({notify: _.noop}); + meta.createPage.returns(Promise.resolve()); - return fn('domain.com/path/_pages', {layout: 'domain.com/path/_components/thing'}).then(function (result) { - expect(result._ref).to.match(/^domain.com\/path\/_pages\//); - delete result._ref; - expect(result).to.deep.equal({layout: 'domain.com/path/_components/thing'}); - }); + return fn('domain.com/path/_pages', {layout: 'domain.com/path/_layouts/thing'}) + .then(result => { + expect(result._ref).to.match(/^domain.com\/path\/_pages\//); + delete result._ref; + expect(result).to.deep.equal({layout: 'domain.com/path/_layouts/thing'}); + }); }); it('creates with content', function () { const uri = 'domain.com/path/_pages', - contentUri = 'domain.com/path/_components/thing1', - layoutUri = 'domain.com/path/_components/thing2', - data = { layout: layoutUri, content: contentUri }, + contentUri = 'domain.com/path/_components/thing1/instances/foo', + layoutUri = 'domain.com/path/_layouts/thing2', + data = { layout: layoutUri, content: [contentUri] }, contentData = {}, layoutReferenceData = {}; - components.get.withArgs(layoutUri).returns(bluebird.resolve(layoutReferenceData)); - components.get.withArgs(contentUri).returns(bluebird.resolve(contentData)); - db.batch.returns(bluebird.resolve()); + layouts.get.withArgs(layoutUri).returns(Promise.resolve(layoutReferenceData)); + components.get.withArgs(contentUri).returns(Promise.resolve(contentData)); + composer.resolveComponentReferences.returns(Promise.resolve({ + content: [{ + _ref: contentUri, + foo: true + }] + })); + db.batch.returns(Promise.resolve()); siteService.getSiteFromPrefix.returns({notify: _.noop}); + meta.createPage.returns(Promise.resolve()); return fn(uri, data).then(function (result) { // self reference is returned, but in a new instance with a new name @@ -81,69 +107,35 @@ describe(_.startCase(filename), function () { expect(result.content).to.match(/^domain\.com\/path\/_components\/thing1\/instances\//); }); }); - - it('creates with content with inner references', function () { - const uri = 'domain.com/path/_pages', - contentUri = 'domain.com/path/_components/thing1', - layoutUri = 'domain.com/path/_components/thing2', - innerContentUri = 'domain.com/path/_components/thing3', - innerContentInstanceUri = 'domain.com/path/_components/thing4/instances/thing5', - data = { layout: layoutUri, content: contentUri }, - contentData = { thing: {_ref: innerContentUri}, instanceThing: {_ref: innerContentInstanceUri}}, - layoutReferenceData = {}, - innerContentInstanceData = {more: 'data'}; - - components.get.withArgs(layoutUri).returns(bluebird.resolve(layoutReferenceData)); - components.get.withArgs(contentUri).returns(bluebird.resolve(contentData)); - components.get.withArgs(innerContentInstanceUri).returns(bluebird.resolve(innerContentInstanceData)); - db.batch.returns(bluebird.resolve()); - siteService.getSiteFromPrefix.returns({notify: _.noop}); - - - return fn(uri, data).then(function (result) { - expect(result._ref).to.match(/^domain\.com\/path\/_pages\//); - expect(result.layout).to.equal(layoutUri); - expect(result.content).to.match(/^domain\.com\/path\/_components\/thing1\/instances\//); - - // This is complex, I know, but we're cloning things and giving them a random name -- Testing random is difficult. - // There should be three ops, each has a unique instance key, and each writes to a unique ref. - // Non-instance references are ignored - - const batchOps = db.batch.args[0][0]; - - expect(batchOps[0].key).to.match(new RegExp('domain.com/path/_components/thing4/instances/')); - expect(batchOps[0].type).to.equal('put'); - expect(JSON.parse(batchOps[0].value)).to.deep.equal(innerContentInstanceData); - - expect(batchOps[1].key).to.match(new RegExp('domain.com/path/_components/thing1/instances/')); - expect(batchOps[1].type).to.equal('put'); - expect(JSON.parse(batchOps[1].value).thing).to.deep.equal({_ref: innerContentUri}); - expect(JSON.parse(batchOps[1].value).instanceThing._ref).to.match(new RegExp('domain.com/path/_components/thing4/instances/')); - - expect(batchOps[2].key).to.match(new RegExp('domain.com/path/_pages/')); - expect(batchOps[2].type).to.equal('put'); - expect(JSON.parse(batchOps[2].value).layout).to.equal(layoutUri); - expect(JSON.parse(batchOps[2].value).content).to.match(new RegExp('domain.com/path/_components/thing1/instances/')); - }); - }); }); describe('publish', function () { const fn = lib[this.title], locals = { site: { - resolvePublishUrl: [ () => Promise.resolve('http://some-domain.com') ] + resolvePublishUrl: [] } }; it('creates relevant uri', function () { - components.get.returns(bluebird.resolve({})); - db.get.returns(Promise.reject()); - db.batch.returns(bluebird.resolve()); - notifications.notify.returns(bluebird.resolve()); + var pageData = { + layout: 'domain.com/path/_layouts/thing', + main: [ 'domain.com/path/_components/foo' ] + }; - return fn('domain.com/path/_pages/thing@published', {layout: 'domain.com/path/_components/thing'}, locals) - .then(function () { + layouts.get.returns(Promise.resolve({})); + components.get.returns(Promise.resolve()); + db.getLatestData.returns(Promise.resolve()); + db.batch.returns(Promise.resolve()); + notifications.notify.returns(Promise.resolve()); + publishService.resolvePublishUrl.returns(() => Promise.resolve({ + meta: { url: 'http://some-domain.com/' }, + data: pageData + })); + meta.publishPage.returns(Promise.resolve()); + + return fn('domain.com/path/_pages/thing@published', pageData, locals) + .then(() => { const ops = db.batch.args[0][0], secondLastOp = ops[ops.length - 2]; @@ -155,17 +147,55 @@ describe(_.startCase(filename), function () { }); }); - it('publishes dynamic pages', function () { - components.get.returns(bluebird.resolve({})); - db.get.returns(Promise.reject()); - db.batch.returns(bluebird.resolve()); - notifications.notify.returns(bluebird.resolve()); + it('finds the site without locals', function () { + var pageData = { + layout: 'domain.com/path/_layouts/thing', + main: [ 'domain.com/path/_components/foo' ] + }; - return fn('domain.com/path/_pages/thing@published', {layout: 'domain.com/path/_components/thing', _dynamic: true}, locals) - .then(function () { + layouts.get.returns(Promise.resolve({})); + components.get.returns(Promise.resolve()); + db.getLatestData.returns(Promise.resolve()); + db.batch.returns(Promise.resolve()); + notifications.notify.returns(Promise.resolve()); + publishService.resolvePublishUrl.returns(() => Promise.resolve({ + meta: { url: 'http://some-domain.com/' }, + data: pageData + })); + meta.publishPage.returns(Promise.resolve()); + siteService.getSiteFromPrefix.returns(locals.site); + + return fn('domain.com/path/_pages/thing@published', pageData, {}) + .then(() => { const ops = db.batch.args[0][0], secondLastOp = ops[ops.length - 2]; + sinon.assert.calledOnce(siteService.getSiteFromPrefix); + expect(secondLastOp).to.deep.equal({ + type: 'put', + key: 'domain.com/path/_uris/c29tZS1kb21haW4uY29tLw==', + value: 'domain.com/path/_pages/thing' + }); + }); + }); + + it('publishes dynamic pages', function () { + const pageData = { layout: 'domain.com/path/_layouts/thing', _dynamic: true }; + + layouts.get.returns(Promise.resolve({})); + db.getLatestData.returns(Promise.resolve()); + db.batch.returns(Promise.resolve()); + notifications.notify.returns(Promise.resolve()); + publishService.resolvePublishUrl.returns(() => Promise.resolve({ + meta: {}, + data: pageData + })); + meta.publishPage.returns(Promise.resolve()); + + return fn('domain.com/path/_pages/thing@published', pageData, locals) + .then(() => { + const ops = db.batch.args[0][0], + secondLastOp = ops[ops.length - 2]; expect(secondLastOp).to.not.deep.equal({ type: 'put', @@ -176,69 +206,55 @@ describe(_.startCase(filename), function () { }); it('warns if publish is slow', function () { - components.get.returns(bluebird.resolve({})); - db.get.returns(Promise.reject()); - db.batch.returns(bluebird.resolve()); - notifications.notify.returns(bluebird.resolve()); - timer.getMillisecondsSince.returns(timeoutConstant * 7); + const pageData = {layout: 'domain.com/path/_layouts/thing'}; - return fn('domain.com/path/_pages/thing@published', {layout: 'domain.com/path/_components/thing'}, locals) - .then(function () { + layouts.get.returns(Promise.resolve({})); + db.get.returns(Promise.resolve()); + db.batch.returns(Promise.resolve()); + notifications.notify.returns(Promise.resolve()); + timer.getMillisecondsSince.returns(timeoutConstant * 7); + publishService.resolvePublishUrl.returns(() => Promise.resolve({ + meta: { url: 'http://some-domain.com/' }, + data: pageData + })); + meta.publishPage.returns(Promise.resolve()); + + return fn('domain.com/path/_pages/thing@published', pageData, locals) + .then(() => { sinon.assert.calledWith(fakeLog, 'warn', sinon.match('slow publish domain.com/path/_pages/thing@published 700ms')); }); }); it('logs if publish is not slow', function () { - components.get.returns(bluebird.resolve({})); - db.get.returns(Promise.reject()); - db.batch.returns(bluebird.resolve()); - notifications.notify.returns(bluebird.resolve()); + var pageData = {layout: 'domain.com/path/_layouts/thing'}; + + layouts.get.returns(Promise.resolve({})); + db.get.returns(Promise.resolve()); + db.batch.returns(Promise.resolve()); + notifications.notify.returns(Promise.resolve()); timer.getMillisecondsSince.returns(20); + publishService.resolvePublishUrl.returns(() => Promise.resolve({ + meta: { url: 'http://some-domain.com/' }, + data: pageData + })); + meta.publishPage.returns(Promise.resolve()); - return fn('domain.com/path/_pages/thing@published', {layout: 'domain.com/path/_components/thing'}, locals) + return fn('domain.com/path/_pages/thing@published', pageData, locals) .then(function () { sinon.assert.calledWith(fakeLog, 'info', sinon.match('published domain.com/path/_pages/thing 20ms')); }); }); - it('warns if notification fails', function () { - const site = {}, - uri = 'domain.com/path/_pages/thing@published'; - - components.get.returns(bluebird.resolve({})); - db.get.returns(Promise.reject()); - db.batch.returns(bluebird.resolve()); - siteService.getSiteFromPrefix.returns(site); - notifications.notify.returns(bluebird.reject(new Error('hello!'))); - - return fn(uri, {layout: 'domain.com/path/_components/thing'}, locals) - .then(function () { - sinon.assert.calledWith(fakeLog, 'warn'); - sinon.assert.calledTwice(fakeLog); // it's called once with info - }); - }); - - it('notifies', function () { - const uri = 'domain.com/path/_pages/thing@published'; - - components.get.returns(bluebird.resolve({})); - db.get.returns(Promise.reject()); - db.batch.returns(bluebird.resolve()); - siteService.getSiteFromPrefix.returns({}); - notifications.notify.returns(bluebird.resolve()); - - return fn(uri, {layout: 'domain.com/path/_components/thing'}, locals) - .then(function (result) { - sinon.assert.calledWith(notifications.notify, locals.site, 'published', result); - }); - }); - it('throws on empty data', function (done) { - components.get.returns(bluebird.resolve({})); - db.batch.returns(bluebird.resolve()); + var pageData = {layout: 'domain.com/path/_layouts/thing', head: ['']}; + + layouts.get.returns(Promise.resolve({})); + db.batch.returns(Promise.resolve()); siteService.getSiteFromPrefix.returns({notify: _.noop}); + publishService.resolvePublishUrl.returns(Promise.resolve(Object.assign(pageData, { url: 'http://some-domain.com/'}))); - fn('domain.com/path/_pages/thing@published', {layout: 'domain.com/path/_components/thing', head: ['']}, { site: {} }) + fn('domain.com/path/_pages/thing@published', pageData, { site: {} }) + .then(done) .catch(function (result) { expect(result.message).to.equal('Client: page cannot have empty values'); done(); @@ -246,43 +262,62 @@ describe(_.startCase(filename), function () { }); it('publishes with provided data', function () { - components.get.returns(bluebird.resolve({})); - db.get.returns(Promise.reject()); - db.batch.returns(bluebird.resolve()); - siteService.getSiteFromPrefix.returns({notify: _.noop}); - notifications.notify.returns(bluebird.resolve()); + var pageData = {layout: 'domain.com/path/_layouts/thing'}; - return fn('domain.com/path/_pages/thing@published', {layout: 'domain.com/path/_components/thing'}, locals) + layouts.get.returns(Promise.resolve({})); + db.get.returns(Promise.resolve()); + db.batch.returns(Promise.resolve()); + siteService.getSiteFromPrefix.returns({notify: _.noop}); + notifications.notify.returns(Promise.resolve()); + publishService.resolvePublishUrl.returns(() => Promise.resolve({ + meta: { url: 'http://some-domain.com/' }, + data: pageData + })); + meta.publishPage.returns(Promise.resolve()); + + return fn('domain.com/path/_pages/thing@published', pageData, locals) .then(function (result) { - expect(result.layout).to.equal('domain.com/path/_components/thing@published'); + expect(result.layout).to.equal('domain.com/path/_layouts/thing@published'); }); }); it('publishes without provided data', function () { - components.get.returns(bluebird.resolve({})); - db.get.returns(bluebird.resolve(JSON.stringify({layout: 'domain.com/path/_components/thing', url: 'http://some-domain.com'}))); - db.batch.returns(bluebird.resolve()); - siteService.getSiteFromPrefix.returns({notify: _.noop}); - notifications.notify.returns(bluebird.resolve()); + var pageData = { + layout: 'domain.com/path/_components/thing@published' + }; - return fn('domain.com/path/_pages/thing@published', {}, locals).then(function (result) { - expect(result).to.deep.equal({ - layout: 'domain.com/path/_components/thing@published', - url: 'http://some-domain.com', - urlHistory: [ 'http://some-domain.com' ] - }); + layouts.get.returns(Promise.resolve({})); + db.get.returns(Promise.resolve({layout: 'domain.com/path/_layouts/thing', url: 'http://some-domain.com'})); + db.batch.returns(Promise.resolve()); + siteService.getSiteFromPrefix.returns({notify: _.noop}); + notifications.notify.returns(Promise.resolve()); + publishService.resolvePublishUrl.returns(() => Promise.resolve({ + meta: { url: 'http://some-domain.com/' }, + data: pageData + })); + meta.publishPage.returns(Promise.resolve()); + + return fn('domain.com/path/_pages/thing@published', {}, locals).then(result => { + expect(result).to.deep.equal(pageData); }); }); it('throws when a sites publishing chain does not provide a url', function () { - components.get.returns(bluebird.resolve({})); - db.batch.returns(bluebird.resolve()); + layouts.get.returns(Promise.resolve({})); + db.batch.returns(Promise.resolve()); siteService.getSiteFromPrefix.returns({notify: _.noop}); - notifications.notify.returns(bluebird.resolve()); + notifications.notify.returns(Promise.resolve()); + publishService.resolvePublishUrl.returns(() => Promise.resolve({ + meta: {}, + data: { + layout: 'domain.com/path/_components/thing@published' + } + })); + meta.publishPage.returns(Promise.resolve()); - return fn('domain.com/path/_pages/thing@published', {layout: 'domain.com/path/_components/thing'}, { site: {} }) - .catch(function (result) { - expect(result.message).to.equal('Unable to determine a url for publishing domain.com/path/_pages/thing'); + return fn('domain.com/path/_pages/thing@published', {layout: 'domain.com/path/_layouts/thing'}, { site: {} }) + .catch(result => { + expect(result.message).to.equal('Client: Page must have valid url to publish.'); }); }); @@ -292,28 +327,18 @@ describe(_.startCase(filename), function () { db.batch.returns(bluebird.resolve()); siteService.getSiteFromPrefix.returns({notify: _.noop}); notifications.notify.returns(bluebird.resolve()); + publishService.resolvePublishUrl.returns(() => Promise.resolve({ + meta: { url: 'womp&com'}, + data: { + layout: 'domain.com/path/_components/thing@published' + } + })); return fn('domain.com/path/_pages/thing@published', {layout: 'domain.com/path/_components/thing', url: 'foo/bar/baz' }, { site: {} }) .catch(function (result) { expect(result.message).to.equal('Client: Page must have valid url to publish.'); }); }); - - it('throws when a site does not provide a resolvePublishing function to modify the publishing chain', function (done) { - components.get.returns(bluebird.resolve({})); - db.batch.returns(bluebird.resolve()); - siteService.getSiteFromPrefix.returns({notify: _.noop}); - notifications.notify.returns(bluebird.resolve()); - - fn('domain.com/path/_pages/thing@published', {layout: 'domain.com/path/_components/thing'}, - { - site: {} - }).then(done) - .catch(function (result) { - expect(result.message).to.equal('Unable to determine a url for publishing domain.com/path/_pages/thing'); - done(); - }); - }); }); describe('replacePageReferenceVersions', function () { @@ -348,54 +373,29 @@ describe(_.startCase(filename), function () { const fn = lib[this.title]; it('passes through if page already exists', function () { - db.get.returns(bluebird.resolve('{}')); - db.put.returns(bluebird.resolve()); - schema.getSchema.returns(Promise.resolve({ _layout: true })); + db.getLatestData.returns(Promise.resolve({})); + db.put.returns(Promise.resolve()); - return fn('domain.com/path/pages', {layout: 'domain.com/path/components/thing'}).then(function () { - expect(plugins.executeHook.called).to.equal(false); - }); + return fn('domain.com/path/pages', {layout: 'domain.com/path/_layouts/thing'}) + .then(() => { + sinon.assert.notCalled(bus.publish); + }); }); it('calls create hook if page does not exist', function () { - db.get.returns(bluebird.reject()); - db.put.returns(bluebird.resolve()); - schema.getSchema.returns(Promise.resolve({ _layout: true })); + db.getLatestData.returns(Promise.reject()); + db.put.returns(Promise.resolve()); - return fn('domain.com/path/pages', {layout: 'domain.com/path/components/thing'}).then(function () { - expect(plugins.executeHook.called).to.equal(true); - expect(plugins.executeHook.getCall(0).args[0]).to.equal('createPage'); + return fn('domain.com/path/pages', {layout: 'domain.com/path/_layouts/thing'}).then(function () { + sinon.assert.calledOnce(bus.publish); + expect(bus.publish.getCall(0).args[0]).to.equal('createPage'); }); }); - it('warns if referenced layout is not a real layout (false)', () => { - db.get.returns(bluebird.resolve('{}')); - db.put.returns(bluebird.resolve()); - schema.getSchema.returns(Promise.resolve({ _layout: false })); + it('errors if the page does not have a layout', () => { + const result = () => fn('domain.com/path/pages', {layout: 'domain.com/path/_components/thing'}); - return fn('domain.com/path/pages', {layout: 'domain.com/path/components/thing'}).then(function () { - sinon.assert.calledWith(fakeLog, 'warn', sinon.match('layout must specify \'_layout: true\' in its schema: domain.com/path/components/thing')); - }); - }); - - it('warns if referenced layout is not a real layout (undefined)', () => { - db.get.returns(bluebird.resolve('{}')); - db.put.returns(bluebird.resolve()); - schema.getSchema.returns(Promise.resolve({})); - - return fn('domain.com/path/pages', {layout: 'domain.com/path/components/thing'}).then(function () { - sinon.assert.calledWith(fakeLog, 'warn', sinon.match('layout must specify \'_layout: true\' in its schema: domain.com/path/components/thing')); - }); - }); - - it('warns if referenced layout is not a real layout (reject)', () => { - db.get.returns(bluebird.resolve('{}')); - db.put.returns(bluebird.resolve()); - schema.getSchema.returns(Promise.reject()); - - return fn('domain.com/path/pages', {layout: 'domain.com/path/components/thing'}).then(function () { - sinon.assert.calledWith(fakeLog, 'warn', sinon.match('layout must specify \'_layout: true\' in its schema: domain.com/path/components/thing')); - }); + expect(result).to.throw(Error); }); }); }); diff --git a/lib/services/plugins.js b/lib/services/plugins.js new file mode 100644 index 00000000..2faa7ca6 --- /dev/null +++ b/lib/services/plugins.js @@ -0,0 +1,75 @@ +'use strict'; + +const bluebird = require('bluebird'), + db = require('../services/db'), + sites = require('../services/sites'), + { getMeta, patchMeta } = require('../services/metadata'), + { publish } = require('../services/bus'); +var log = require('./logger').setup({ + file: __filename + }), + pluginDBAdapter; + + +/** + * Call each plugin and pass along the router, + * a subset of the db service the publish method + * of the bus service. + * + * @param {Object} router + * @returns {Promise} + */ +function initPlugins(router) { + return bluebird.all(module.exports.plugins.map(plugin => { + return bluebird.try(() => { + if (typeof plugin === 'function') { + return plugin(router, pluginDBAdapter, publish, sites); + } else { + log('warn', 'Plugin is not a function'); + return bluebird.resolve(); + } + }); + })); +} + +/** + * Builds the db object to pass to + * the plugin. Needs to use the metadata + * functions and not pass along `putMeta` + * + * @returns {Object} + */ +function createDBAdapter() { + var returnObj = { + getMeta, + patchMeta + }; + + for (const key in db) { + /* istanbul ignore else */ + if (db.hasOwnProperty(key) && key.indexOf('Meta') === -1) { + returnObj[key] = db[key]; + } + } + + return returnObj; +} + +/** + * Register plugins passed in at instantiation time. + * Also fires the `init` hook. + * + * @param {Array} plugins [description]] + */ +function registerPlugins(plugins) { + // Need to wrap the DB methods in automatic publish to bus + pluginDBAdapter = createDBAdapter(); + module.exports.plugins = plugins; +} + +module.exports.plugins = []; +module.exports.registerPlugins = registerPlugins; +module.exports.initPlugins = initPlugins; + +// For testing +module.exports.setLog = mock => log = mock; diff --git a/lib/services/plugins.test.js b/lib/services/plugins.test.js new file mode 100644 index 00000000..1fffbda3 --- /dev/null +++ b/lib/services/plugins.test.js @@ -0,0 +1,65 @@ +'use strict'; + +const _ = require('lodash'), + filename = __filename.split('/').pop().split('.').shift(), + lib = require('./' + filename), + sinon = require('sinon'), + files = require('../files'), + siteService = require('./sites'), + composer = require('./composer'), + models = require('./models'), + expect = require('chai').expect; + +describe(_.startCase(filename), function () { + let sandbox, plugin, fakeLog; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + + plugin = sandbox.stub(); + fakeLog = sandbox.stub(); + lib.setLog(fakeLog); + sandbox.stub(composer, 'resolveComponentReferences'); + sandbox.stub(siteService); + sandbox.stub(files, 'getComponentModule'); + sandbox.stub(models, 'put'); + sandbox.stub(models, 'get'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('registerPlugins', function () { + const fn = lib[this.title]; + + it('registers plugins', function () { + fn([_.noop]); + expect(lib.plugins).to.eql([_.noop]); + }); + }); + + describe('initPlugins', function () { + const fn = lib[this.title]; + + it('will try to invoke each plugin', function () { + lib.plugins = [plugin]; + plugin.returns(true); + + return fn() + .then(() => { + sinon.assert.calledOnce(plugin); + }); + }); + + it('logs a warning if a plugin is not a function', function () { + lib.plugins = [false]; + plugin.returns(true); + + return fn() + .then(() => { + sinon.assert.calledWith(fakeLog, 'warn', 'Plugin is not a function'); + }); + }); + }); +}); diff --git a/lib/services/publish.js b/lib/services/publish.js index ad31e79e..8c47fac2 100644 --- a/lib/services/publish.js +++ b/lib/services/publish.js @@ -8,75 +8,52 @@ const _ = require('lodash'), bluebird = require('bluebird'), { parse } = require('url'), - db = require('./db'), buf = require('./buffer'), + meta = require('./metadata'), { getPrefix, replaceVersion } = require('clayutils'); -var log = require('./logger').setup({ file: __filename, action: 'pagePublish' }); +var db = require('./db'), + log = require('./logger').setup({ file: __filename, action: 'pagePublish' }); /** * grab any old urls from the published page (if it exist) and save them in the current page * note: we're modifying data in place (but returning the url history) - * @param {String} url + * + * @param {Object} metaObj * @param {String} uri - * @param {Object} data + * @param {String} url * @returns {Promise} */ -function storeUrlHistory(url, uri, data) { +function storeUrlHistory(metaObj, uri, url) { // get the published page (if it exists) and update the urlHistory array - return db.get(uri) - .then(JSON.parse) - .catch(function () { - // if this is the first time we're publishing, db.get() won't find - // the published page data. thus, return empty object so we can add the urlHistory - return {}; - }) - .then(function (publishedPageData) { - data.urlHistory = publishedPageData.urlHistory || []; + return meta.getMeta(uri) + .then(metaData => { + metaObj.urlHistory = metaData.urlHistory || []; - if (_.last(data.urlHistory) !== url) { + if (_.last(metaData.urlHistory) !== url) { // only add urls if they've changed // note: this checks the last url, so you can revert urls you don't like, e.g. // originally published with: domain.com/foo.html // re-published with: domain.com/bar.html // reverted (re-published again) with: domain.com/foo.html - data.urlHistory.push(url); + metaObj.urlHistory.push(url); } - return data.urlHistory; // always return url history + return metaObj; // always return url history }); } -/** - * Given page data, locals and a generated url make - * sure the new url is added to all the appropriate - * places for saving published data - * - * @param {String} generatedUrl - * @param {Object} page - * @param {Object} locals - * @returns {Promise} - */ -function updatePageAndLocalsWithUrl(generatedUrl, page, locals) { - // Add the url property to the page. If this line is removed, - // everything breaks (unless `customUrl` is being used). Trust me. - page.url = generatedUrl; - - // set the publishUrl for component's model.js files that - // may want to know about the URL of the page - locals.publishUrl = generatedUrl; - - return bluebird.resolve(generatedUrl); -} - /** * add a 301 redirect to the previous uri * note: we only need to add it to the previous one, because ones before will point * to it, e.g. /one.html → /two.html → /three.html * multiple 301s on the same server have no performance penalties - * @param {array} urlHistory + * + * @param {Object} metaObj * @param {string} uri * @returns {Promise|undefined} */ -function addRedirects(urlHistory, uri) { +function addRedirects(metaObj, uri) { + const { urlHistory } = metaObj; + if (urlHistory.length > 1) { // there are old urls! redirect the previous url to latest url let prevUrl = parse(urlHistory[urlHistory.length - 2]), @@ -86,8 +63,11 @@ function addRedirects(urlHistory, uri) { prefix = getPrefix(uri); // update the previous uri to point to the latest uri - return db.put(`${prefix}/_uris/${buf.encode(prevUri)}`, `${prefix}/_uris/${buf.encode(newUri)}`); + return db.put(`${prefix}/_uris/${buf.encode(prevUri)}`, `${prefix}/_uris/${buf.encode(newUri)}`) + .then(() => metaObj); } + + return metaObj; } /** @@ -126,6 +106,26 @@ function _checkForDynamicPage(uri, { _dynamic }) { return bluebird.resolve(_dynamic); } +/** + * Allow functions to add data to the meta object. + * Functions are defined on the site controller and + * can be synchronous or async. + * + * @param {Object} meta + * @param {Array} modifiers + * @param {String} uri + * @param {Object} data + * @returns {Promise} + */ +function addToMeta(meta, modifiers = [], uri, data) { + if (!modifiers.length) return meta; + + return bluebird.reduce(modifiers, (acc, modify) => { + return bluebird.try(modify.bind(null, uri, data, acc)) + .then(resp => Object.assign(acc, resp)); + }, {}).then(acc => Object.assign(acc, meta)); +} + /** * Always do these actins when publishing a page * @@ -146,30 +146,14 @@ function publishPageAtUrl(url, uri, data, locals, site) { // eslint-disable-line return data; } - return updatePageAndLocalsWithUrl(url, data, locals) - .then(url => storeUrlHistory(url, uri, data)) - .then(urlHistory => addRedirects(urlHistory, uri)) - .then(() => modifyPublishedData(site, data)); -} - -/** - * Allow site to append/change data on a published page before - * publishing. Hooks are specified in the `modifyPublishedData` - * Array in the site's index.js and should be an Array of - * synchronous or asynchronous functions - * - * @param {Array} modifyPublishedData - * @param {Object} data - * @return {Promise} - */ -function modifyPublishedData({ modifyPublishedData }, data) { - if (!_.isArray(modifyPublishedData)) { - return bluebird.resolve(data); - } + // set the publishUrl for component's model.js files that + // may want to know about the URL of the page + locals.publishUrl = url; - return bluebird.reduce(modifyPublishedData, function (acc, val) { - return bluebird.try(val.bind(null, acc)); - }, data); + return bluebird.resolve({ url }) + .then(metaObj => storeUrlHistory(metaObj, uri, url)) + .then(metaObj => addRedirects(metaObj, uri)) + .then(metaObj => addToMeta(metaObj, site.assignToMetaOnPublish, uri, data)); } /** @@ -228,7 +212,7 @@ function validatePublishRules(uri, { val, errors }) { } function resolvePublishUrl(uri, locals, site) { - return function (pageData) { + return pageData => { /** * The publishing chain is an array of functions which either return a function to publish a page * or throw an error. This is a variant of the Chain of Responsibility pattern, which we're calling Reject Quickly/Resolve Slowly. @@ -238,23 +222,25 @@ function resolvePublishUrl(uri, locals, site) { var publishingChain = [_checkForUrlProperty, _checkForDynamicPage]; if (site.resolvePublishUrl && Array.isArray(site.resolvePublishUrl)) { - publishingChain = site.resolvePublishUrl.concat(publishingChain); + publishingChain = publishingChain.concat(site.resolvePublishUrl); } // Iterate over an array of publishing functions sequentially to find the first one which resolves // return chain(publishingChain, uri, _.cloneDeep(pageData), locals) return processPublishRules(publishingChain, uri, _.cloneDeep(pageData), locals) .then(resp => validatePublishRules(uri, resp)) - .then(url => publishPageAtUrl(url, uri, pageData, locals, site)); + .then(url => publishPageAtUrl(url, uri, pageData, locals, site)) + .then(meta => ({ meta, data: pageData })); }; } -module.exports = resolvePublishUrl; +module.exports.resolvePublishUrl = resolvePublishUrl; // For testing module.exports._checkForUrlProperty = _checkForUrlProperty; module.exports._checkForDynamicPage = _checkForDynamicPage; -module.exports.modifyPublishedData = modifyPublishedData; module.exports.processPublishRules = processPublishRules; module.exports.validatePublishRules = validatePublishRules; +module.exports.addToMeta = addToMeta; module.exports.setLog = mock => log = mock; +module.exports.setDb = mock => db = mock; diff --git a/lib/services/publish.test.js b/lib/services/publish.test.js index 086d58ff..d5720fca 100644 --- a/lib/services/publish.test.js +++ b/lib/services/publish.test.js @@ -4,14 +4,16 @@ const _ = require('lodash'), sinon = require('sinon'), expect = require('chai').expect, filename = __filename.split('/').pop().split('.').shift(), - db = require('../services/db'), - lib = require(`./${filename}`); + lib = require(`./${filename}`), + storage = require('../../test/fixtures/mocks/storage'), + meta = require('./metadata'); describe(_.startCase(filename), function () { let sandbox, fakeSite, pubRule, modifyFn, + db, fakeLog, rule1, rule2, rule3; @@ -31,13 +33,15 @@ describe(_.startCase(filename), function () { beforeEach(function () { sandbox = sinon.sandbox.create(); - sandbox.stub(db); + db = storage(); rule1 = sandbox.stub(); rule2 = sandbox.stub(); rule3 = sandbox.stub(); fakeLog = sandbox.stub(); + sandbox.stub(meta, 'getMeta'); lib.setLog(fakeLog); + lib.setDb(db); makeFakeSite(); }); @@ -87,11 +91,9 @@ describe(_.startCase(filename), function () { pubRule.returns(fakeUrl); modifyFn.returnsArg(0); db.get.returns(Promise.resolve(JSON.stringify(fakePage))); - - return lib('some/uri', {}, {})(_.cloneDeep(fakePage)) - .then(function (resp) { - expect(resp).to.eql(_.assign(fakePage, {url: fakeUrl, urlHistory: [ fakeUrl ] })); - }); + meta.getMeta.returns(Promise.resolve({})); + return lib.resolvePublishUrl('some/uri', {}, {})(_.cloneDeep(fakePage)) + .then(resp => expect(resp).to.eql({ data: fakePage, meta: { url: 'http://some.url', urlHistory: ['http://some.url'] }})); }); it('executes a publish rule if one is provided', function () { @@ -100,41 +102,30 @@ describe(_.startCase(filename), function () { pubRule.returns(fakeUrl); modifyFn.returnsArg(0); - db.get.returns(Promise.resolve(JSON.stringify(fakePage))); - - return lib('some/uri', {}, fakeSite)(_.cloneDeep(fakePage)) - .then(function (resp) { - sinon.assert.calledOnce(pubRule); - expect(resp).to.eql(_.assign(fakePage, {url: fakeUrl,urlHistory: [ fakeUrl ] })); - }); - }); + db.get.returns(Promise.resolve(fakePage)); + meta.getMeta.returns(Promise.resolve({})); - it('throws an error if one occurs anywhere in the process', function () { - const fakePage = {page: 'data'}, - fakeUrl = 'http://some.url'; - - pubRule.returns(fakeUrl); - modifyFn.rejects(new Error('An error occured')); - db.get.returns(Promise.resolve(JSON.stringify(fakePage))); - - return lib('some/uri', {}, fakeSite)(_.cloneDeep(fakePage)) - .catch(function (e) { + return lib.resolvePublishUrl('some/uri', {}, fakeSite)(_.cloneDeep(fakePage)) + .then(resp => { sinon.assert.calledOnce(pubRule); - expect(e.message).to.equal('An error occured'); + expect(resp).to.eql({ data: fakePage, meta: { url: fakeUrl, urlHistory: [fakeUrl] }}); }); }); it('adds redirects if needed', function () { const fakeHistory = ['http://old.url', 'http://old2.url'], - fakePage = {page: 'data', urlHistory: fakeHistory}, + fakePage = {page: 'data'}, fakeUrl = 'http://some.url'; pubRule.returns(fakeUrl); modifyFn.returnsArg(0); - db.get.returns(Promise.resolve(JSON.stringify(fakePage))); + db.put.returns(Promise.resolve()); + meta.getMeta.returns(Promise.resolve({ + urlHistory: fakeHistory + })); - return lib('some/uri', {}, fakeSite)(_.cloneDeep(fakePage)) - .then(function () { + return lib.resolvePublishUrl('some/uri', {}, fakeSite)(_.cloneDeep(fakePage)) + .then(() => { sinon.assert.calledOnce(db.put); }); }); @@ -142,15 +133,18 @@ describe(_.startCase(filename), function () { it('does not update history if the url is not new', function () { const fakeUrl = 'http://some.url', fakeHistory = ['http://old.url', fakeUrl], - fakePage = {page: 'data', urlHistory: fakeHistory}; + fakePage = {page: 'data'}; pubRule.returns(fakeUrl); modifyFn.returnsArg(0); - db.get.returns(Promise.resolve(JSON.stringify(fakePage))); + db.put.returns(Promise.resolve()); + meta.getMeta.returns(Promise.resolve({ + urlHistory: fakeHistory + })); - return lib('some/uri', {}, fakeSite)(_.cloneDeep(fakePage)) - .then(function (resp) { - expect(resp).to.eql(_.assign(fakePage, {url: fakeUrl})); + return lib.resolvePublishUrl('some/uri', {}, fakeSite)(_.cloneDeep(fakePage)) + .then(resp => { + expect(resp).to.eql({ data: fakePage, meta: { url: fakeUrl, urlHistory: fakeHistory }}); }); }); @@ -160,11 +154,11 @@ describe(_.startCase(filename), function () { pubRule.returns(fakeUrl); modifyFn.returnsArg(0); - db.get.rejects(new Error('not found')); + meta.getMeta.returns(Promise.resolve({})); - return lib('some/uri', {}, fakeSite)(_.cloneDeep(fakePage)) - .then(function (resp) { - expect(resp).to.eql(_.assign(fakePage, {urlHistory: [fakeUrl]})); + return lib.resolvePublishUrl('some/uri', {}, fakeSite)(_.cloneDeep(fakePage)) + .then(resp => { + expect(resp).to.eql({ data: fakePage, meta: { url: fakeUrl, urlHistory: [fakeUrl] }}); }); }); @@ -172,33 +166,23 @@ describe(_.startCase(filename), function () { const fakePage = {page: 'data', _dynamic: true}, locals = {}; - return lib('some/uri', locals, fakeSite)(_.cloneDeep(fakePage)) - .then(function (resp) { - expect(resp._dynamic).to.be.true; + return lib.resolvePublishUrl('some/uri', locals, fakeSite)(_.cloneDeep(fakePage)) + .then(resp => { + expect(resp.meta._dynamic).to.be.true; expect(locals.isDynamicPublishUrl).to.be.true; }); }); }); - describe('modifyPublishedData', function () { + describe('addToMeta', function () { const fn = lib[this.title]; - it('returns the data passed in if no modifiers are defined', function () { - const fakeData = {test: 'data'}; - - return fn({}, fakeData) - .then(function (resp) { - expect(resp).to.eql(fakeData); - }); - }); - - it('throws an error if there is a problem with the modifier', function () { - const fakeData = {test: 'data'}; + it('reduces through modifiers and assigns them to the meta', function () { + const func = sandbox.stub().returns({}); - modifyFn.rejects(new Error('An error occured')); - return fn(fakeSite, fakeData) - .catch(function (e) { - expect(e.message).to.equal('An error occured'); + return fn({}, [func], 'someUri', {}) + .then(() => { + sinon.assert.calledWith(func, 'someUri', {}, {}); }); }); }); diff --git a/lib/services/readme.md b/lib/services/readme.md deleted file mode 100644 index 8490f322..00000000 --- a/lib/services/readme.md +++ /dev/null @@ -1,7 +0,0 @@ -Services -======== - -No _amphora-specific logic_ lives in this directory. They represent actions that someone has decided to take. These -services can be exposed to outside components, such as `server.js` functions. - -Because they represent discrete actions, there's no excuse for not having 100% test coverage. : ) diff --git a/lib/services/references.js b/lib/services/references.js index 26bfdcf9..a699fe23 100644 --- a/lib/services/references.js +++ b/lib/services/references.js @@ -2,7 +2,7 @@ const _ = require('lodash'), urlParse = require('url'), - { replaceVersion, getPrefix } = require('clayutils'); + { replaceVersion } = require('clayutils'); let propagatingVersions = ['published', 'latest']; /** @@ -155,7 +155,3 @@ module.exports.urlToUri = urlToUri; module.exports.omitPageConfiguration = omitPageConfiguration; module.exports.getPageReferences = getPageReferences; module.exports.listDeepObjects = listDeepObjects; - -// TODO: Remove these on next major release -module.exports.getPagePrefix = getPrefix; -module.exports.getUriPrefix = getPrefix; diff --git a/lib/services/schedule.js b/lib/services/schedule.js deleted file mode 100644 index b92a55f1..00000000 --- a/lib/services/schedule.js +++ /dev/null @@ -1,218 +0,0 @@ -'use strict'; - -var interval, - intervalDelay = 50000 + Math.floor(Math.random() * 10000), // just under one minute - log = require('./logger').setup({ - file: __filename - }); -const _ = require('lodash'), - bluebird = require('bluebird'), - { replaceVersion, isPage } = require('clayutils'), - db = require('./db'), - references = require('./references'), - buf = require('./buffer'), - rest = require('../rest'), - siteService = require('./sites'), - plugins = require('../plugins'), - bus = require('./bus'), - publishProperty = 'publish', - scheduledAtProperty = 'at', - scheduledVersion = 'scheduled'; - -/** - * @param {number} value - */ -function setScheduleInterval(value) { - intervalDelay = value; -} - -/** - * Note: There is nothing to do if this fails except log - * @param {string} url - * @returns {Promise} - */ -function publishExternally(url) { - return bluebird.try(function () { - const published = replaceVersion(url, 'published'); - - return rest.putObject(published) - .then(function (res) { - if (res && !res.ok) { - log('error', 'failed to publish url from schedule', { url, status: res.status, statusText: res.statusText }); - } - - return res; - }); - }); -} - -/** - * Get all the publishable things in the schedule - * @param {[{key: string, value: string}]} list - * @param {number} now - * @returns {[{key: string, value: {at: number, publish: string}}]} - */ -function getPublishableItems(list, now) { - // attempt to convert JSON to Object - list = _.compact(_.map(list, function (item) { - try { - item.value = item.value && JSON.parse(item.value); - return item; - } catch (ex) { - log('error', `Cannot parse JSON of ${item.value}`); - } - })); - - return _.filter(list, function (item) { - return item.value && item.value[scheduledAtProperty] < now; - }); -} - -/** - * Create the id for the new item - * - * NOTE: Do not rely on how this ID is created. This might (and should) be changed to something more - * random (like a cid). - * - * @param {string} uri - * @param {object} data - * @returns {string} - * @throws Error if missing "at" or "publish" properties - */ -function createScheduleObjectKey(uri, data) { - const prefix = uri.substr(0, uri.indexOf('/_schedule')), - at = data[scheduledAtProperty], - publish = data[publishProperty]; - - if (!_.isNumber(at)) { - throw new Error('Client: Missing "at" property as number.'); - } else if (!references.isUrl(publish)) { - throw new Error('Client: Missing "publish" property as valid url.'); - } - - return `${prefix}/_schedule/${buf.encode(publish.replace(/https?:\/\//, ''))}`; -} - -/** - * NOTE: We _cannot_ delete without knowing the thing that was published because we need to delete - * the @scheduled location as well. - * - * @param {string} uri - * @param {object} user - * @returns {Promise} - */ -function del(uri, user) { - return db.get(uri).then(JSON.parse).then(function (data) { - const targetUri = references.urlToUri(data[publishProperty]), - targetReference = replaceVersion(targetUri, scheduledVersion), - ops = [ - { type: 'del', key: uri }, - { type: 'del', key: targetReference } - ]; - - return db.batch(ops).then(function () { - if (isPage(targetUri)) { - let obj = { uri: targetUri, data: data, user: user }; - - plugins.executeHook('unschedulePage', obj); - bus.publish('unschedulePage', JSON.stringify(obj)); - } - }).return(data); - }); -} - -/** - * Create a schedule item to publish something in the future - * @param {string} uri - * @param {object} data - * @param {object} user - * @returns {Promise} - */ -function post(uri, data, user) { - const reference = createScheduleObjectKey(uri, data), - targetUri = references.urlToUri(data[publishProperty]), - targetReference = replaceVersion(targetUri, scheduledVersion), - referencedData = _.assign({_ref: reference}, data), - ops = [ - { type: 'put', key: reference, value: JSON.stringify(data) }, - { type: 'put', key: targetReference, value: JSON.stringify(referencedData) } - ]; - - return db.batch(ops).then(function () { - log('info', `scheduled ${targetUri} (${data.at})`); - if (isPage(targetUri)) { - let obj = { uri: targetUri, data: data, user: user }; - - plugins.executeHook('schedulePage', { uri: targetUri, data: data, user: user }); - bus.publish('schedulePage', JSON.stringify(obj)); - } - }).return(referencedData); -} - -/** - * @param {[Promise]} promises - * @param {{key: string, value: string}} item - * @returns {[Promise]} - */ -function publishByTime(promises, item) { - promises.push(publishExternally(item.value[publishProperty]) - .then(() => del(item.key))); - - return promises; -} - -/** - * @param {[{key: string, value: string}]} list - * @returns {Promise} - */ -function publishEachByTime(list) { - // list is assumed to be in order of when they should run - const now = new Date().getTime(); - - return bluebird.all(_.reduce(getPublishableItems(list, now), publishByTime, [])); -} - -/** - * Start waiting for things to publish (but only if we're not already listening) - */ -function startListening() { - if (!interval) { - interval = setInterval(function () { - // get list for each site - _.each(siteService.sites(), function (site) { - db.pipeToPromise(db.list({ - prefix: `${site.host}${site.path}/_schedule`, - keys: true, - values: true, - isArray: true - })).then(JSON.parse) - .then(publishEachByTime) - .catch(function (error) { - log('error', `failed to publish: ${error.message}`); - }); - }); - }, intervalDelay); - } -} - -/** - * Stop waiting for things to publish - */ -function stopListening() { - if (interval) { - clearInterval(interval); - interval = null; - } -} - -module.exports.post = post; -module.exports.del = del; -module.exports.startListening = startListening; -module.exports.stopListening = stopListening; -module.exports.setScheduleInterval = setScheduleInterval; - -// For testing -module.exports.publishExternally = publishExternally; -module.exports.setLog = function (fakeLogger) { - log = fakeLogger; -}; diff --git a/lib/services/schedule.test.js b/lib/services/schedule.test.js deleted file mode 100644 index 41549a01..00000000 --- a/lib/services/schedule.test.js +++ /dev/null @@ -1,223 +0,0 @@ -'use strict'; - -const _ = require('lodash'), - bluebird = require('bluebird'), - db = require('./db'), - expect = require('chai').expect, - filename = __filename.split('/').pop().split('.').shift(), - lib = require('./' + filename), - rest = require('../rest'), - sinon = require('sinon'), - siteService = require('./sites'); - -describe(_.startCase(filename), function () { - let sandbox, - intervalDelay = 100; - - beforeEach(function () { - sandbox = sinon.sandbox.create(); - lib.setLog(_.noop); - sandbox.useFakeTimers(); - lib.setScheduleInterval(intervalDelay); - sandbox.stub(db, 'get'); - sandbox.stub(db, 'put'); - sandbox.stub(db, 'batch'); - sandbox.stub(db, 'pipeToPromise'); - sandbox.stub(siteService, 'sites'); - sandbox.stub(rest); - }); - - afterEach(function () { - lib.stopListening(); - sandbox.restore(); - }); - - describe('post', function () { - const fn = lib[this.title]; - - it('throws on missing "at" property', function (done) { - const ref = 'domain/_pages/some-name', - data = {publish: 'http://abcg'}; - - expect(function () { - fn(ref, data).nodeify(done); - }).to.throw(); - - done(); - }); - - it('throws on missing "publish" property', function (done) { - const ref = 'domain/_pages/some-name', - data = {at: 123}; - - expect(function () { - fn(ref, data).nodeify(done); - }).to.throw(); - - done(); - }); - - it('schedules a publish of abc at 123', function () { - const ref = 'domain/_schedule', - data = {at: 123, publish: 'http://abc/_pages/def'}; - - db.batch.returns(bluebird.resolve({})); - - return fn(ref, data).then(function () { - sinon.assert.calledWith(db.batch, [{ - key: 'domain/_schedule/YWJjL19wYWdlcy9kZWY=', - type: 'put', - value: '{"at":123,"publish":"http://abc/_pages/def"}' - }, { - key: 'abc/_pages/def@scheduled', - type: 'put', - value: '{"_ref":"domain/_schedule/YWJjL19wYWdlcy9kZWY=","at":123,"publish":"http://abc/_pages/def"}' - }]); - }); - }); - }); - - describe('del', function () { - const fn = lib[this.title]; - - it('deletes a scheduled item', function () { - const publishTarget = 'http://abc/_pages/def', - publishDate = 123, - ref = 'domain/_schedule/some-specific-id', - data = {at: 123, publish: publishTarget}; - - db.get.returns(bluebird.resolve(JSON.stringify({at: publishDate, publish: publishTarget}))); - db.batch.returns(bluebird.resolve()); - - return fn(ref, data).then(function () { - sinon.assert.calledWith(db.batch, [ - { key: ref, type: 'del' }, - { key: 'abc/_pages/def@scheduled', type: 'del' } - ]); - }); - }); - }); - - describe('startListening', function () { - const fn = lib[this.title]; - - it('does not throw if started twice', function () { - rest.getObject.returns(bluebird.resolve({})); - rest.putObject.returns(bluebird.resolve({})); - - expect(function () { - fn(); - fn(); - }).to.not.throw(); - }); - - it('publishes', function (done) { - const uri = 'http://abce', - scheduledItem = {at: intervalDelay - 1, publish: uri}, - data = {key:'some-key', value: JSON.stringify(scheduledItem)}; - - rest.putObject = () => done(); // call done without passing data - siteService.sites.returns([{host: 'a', path: '/'}]); - db.pipeToPromise.returns(bluebird.resolve(JSON.stringify([data]))); - db.get.returns(bluebird.resolve(JSON.stringify(scheduledItem))); - db.batch.returns(bluebird.resolve()); - - fn(); - - sandbox.clock.tick(intervalDelay); - }); - - it('logs error if failed to publish page', function (done) { - bluebird.onPossiblyUnhandledRejection(_.noop); - // rejection.suppressUnhandledRejections(); when bluebird supports it better - - const uri = 'http://abce', - scheduledItem = {at: intervalDelay - 1, publish: uri}, - data = {key:'some-key', value: JSON.stringify(scheduledItem)}; - - rest.putObject.returns(bluebird.reject(new Error(''))); - siteService.sites.returns([{host: 'a', path: '/'}]); - db.pipeToPromise.returns(bluebird.resolve(JSON.stringify([data]))); - db.get.returns(bluebird.resolve(JSON.stringify(scheduledItem))); - db.batch.returns(bluebird.resolve()); - - lib.setLog(function (logType, msg) { - expect(logType).to.equal('error'); - expect(msg).to.match(/failed to publish/); - done(); - }); - - fn(); - sandbox.clock.tick(intervalDelay); - }); - - it('logs if the publish request is not ok', function (done) { - const uri = 'http://abce', - scheduledItem = {at: intervalDelay - 1, publish: uri}, - data = {key:'some-key', value: JSON.stringify(scheduledItem)}; - - rest.putObject.returns(bluebird.resolve({ok: false})); - siteService.sites.returns([{host: 'a', path: '/'}]); - db.pipeToPromise.returns(bluebird.resolve(JSON.stringify([data]))); - db.get.returns(bluebird.resolve(JSON.stringify(scheduledItem))); - db.batch.returns(bluebird.resolve()); - - lib.setLog(function (logType, msg) { - expect(logType).to.equal('error'); - expect(msg).to.match(/failed to publish/); - done(); - }); - - fn(); - sandbox.clock.tick(intervalDelay); - }); - - it('logs error if failed to parse JSON', function (done) { - const uri = 'abce/_pages/abcd', - data = {key:'some-key', value: JSON.stringify({at: intervalDelay - 1, publish: uri}).substring(5)}; - - siteService.sites.returns([{host: 'a', path: '/'}]); - db.pipeToPromise.returns(bluebird.resolve(JSON.stringify([data]))); - - lib.setLog(function (logType, msg) { - expect(logType).to.equal('error'); - expect(msg).to.match(/failed to publish/); - done(); - }); - - fn(); - sandbox.clock.tick(intervalDelay); - }); - - it('logs error if missing publish attribute', function (done) { - const uri = 'abce/unknown/abcd', - data = {key:'some-key', value: JSON.stringify({at: intervalDelay - 1, publish: uri})}; - - siteService.sites.returns([{host: 'a', path: '/'}]); - db.pipeToPromise.returns(bluebird.resolve(JSON.stringify([data]))); - - lib.setLog(function (logType, msg) { - expect(logType).to.equal('error'); - expect(msg).to.match(/failed to publish/); - done(); - }); - - fn(); - sandbox.clock.tick(intervalDelay); - }); - }); - - describe('publishExternally', function () { - const fn = lib[this.title]; - - it('handles a false-y response from the rest service', function () { - const uri = 'http://abce'; - - rest.putObject.returns(bluebird.resolve(null)); - return fn(uri) - .then(function (res) { - expect(res).to.be.null; - }); - }); - }); -}); diff --git a/lib/services/sites.js b/lib/services/sites.js index 8d805a26..17c42284 100644 --- a/lib/services/sites.js +++ b/lib/services/sites.js @@ -139,4 +139,4 @@ module.exports.getSiteFromPrefix = getSiteFromPrefix; // exported for tests module.exports.normalizePath = normalizePath; module.exports.normalizeDirectory = normalizeDirectory; -module.exports.setLog = (fakeLogger) => { log = fakeLogger; }; +module.exports.setLog = mock => log = mock; \ No newline at end of file diff --git a/lib/services/upgrade.js b/lib/services/upgrade.js index 647f864e..c1a2c553 100644 --- a/lib/services/upgrade.js +++ b/lib/services/upgrade.js @@ -2,16 +2,16 @@ 'use strict'; const _ = require('lodash'), - db = require('./db'), bluebird = require('bluebird'), files = require('../files'), path = require('path'), utils = require('../utils/schema'), - { getComponentName } = require('clayutils'); -var log = require('./logger').setup({ - file: __filename, - action: 'upgrade' -}); + { getComponentName, getLayoutName, isComponent } = require('clayutils'); +var db = require('./db'), + log = require('./logger').setup({ + file: __filename, + action: 'upgrade' + }); /** * Just do a quick sort, lowest to highest. @@ -142,17 +142,17 @@ function saveTransformedData(uri) { * @return {Promise} */ function upgradeData(schemaVersion, dataVersion, ref, data, locals) { - const componentName = getComponentName(ref), - componentDir = files.getComponentPath(componentName), // Get the component directory - upgradeFile = files.tryRequire(path.resolve(componentDir, 'upgrade')); // Grab the the upgrade.js file + const name = isComponent(ref) ? getComponentName(ref) : getLayoutName(ref), + dir = isComponent(ref) ? files.getComponentPath(name) : files.getLayoutPath(name), // Get the component directory + upgradeFile = files.tryRequire(path.resolve(dir, 'upgrade')); // Grab the the upgrade.js file var transforms = []; - log('debug', `Running upgrade for ${componentName}: ${ref}`); + log('debug', `Running upgrade for ${name}: ${ref}`); // If no upgrade file exists, exit early if (!upgradeFile) { - log('debug', `No upgrade file found for component: ${componentName}`); + log('debug', `No upgrade file found for component: ${name}`); return bluebird.resolve(data); } @@ -161,8 +161,8 @@ function upgradeData(schemaVersion, dataVersion, ref, data, locals) { // If no transforms need to be run, exit early if (!transforms.length) { - log('debug', `Upgrade tried to run, but no upgrade function was found for ${componentName}`, { - component: componentName, + log('debug', `Upgrade tried to run, but no upgrade function was found for ${name}`, { + component: name, currentVersion: dataVersion, schemaVersion }); @@ -190,7 +190,7 @@ function upgradeData(schemaVersion, dataVersion, ref, data, locals) { */ function checkForUpgrade(ref, data, locals) { return utils.getSchema(ref) - .then(function (schema) { + .then(schema => { // If version does not match what's in the data if (schema && schema._version && schema._version !== data._version) { return module.exports.upgradeData(schema._version, data._version, ref, data, locals); @@ -220,6 +220,5 @@ module.exports.upgradeData = upgradeData; module.exports.checkForUpgrade = checkForUpgrade; module.exports.generateVersionArray = generateVersionArray; module.exports.aggregateTransforms = aggregateTransforms; -module.exports.setLog = function (fakeLogger) { - log = fakeLogger; -}; +module.exports.setLog = mock => log = mock; +module.exports.setDb = mock => db = mock; diff --git a/lib/services/upgrade.test.js b/lib/services/upgrade.test.js index 2c512856..126f8b30 100644 --- a/lib/services/upgrade.test.js +++ b/lib/services/upgrade.test.js @@ -5,11 +5,11 @@ const _ = require('lodash'), filename = __filename.split('/').pop().split('.').shift(), lib = require('./' + filename), sinon = require('sinon'), - db = require('./db'), bluebird = require('bluebird'), utils = require('../utils/schema'), files = require('../files'), - expect = require('chai').expect; + expect = require('chai').expect, + storage = require('../../test/fixtures/mocks/storage'); function returnData(ref, data) { return data; @@ -25,7 +25,7 @@ function fakeUgrades() { } describe(_.startCase(filename), function () { - let sandbox, fakeLog; + let sandbox, fakeLog, db; beforeEach(function () { sandbox = sinon.sandbox.create(); @@ -34,7 +34,8 @@ describe(_.startCase(filename), function () { lib.setLog(fakeLog); sandbox.stub(utils); sandbox.stub(files); - sandbox.stub(db); + db = storage(); + lib.setDb(db); }); afterEach(function () { @@ -113,6 +114,15 @@ describe(_.startCase(filename), function () { }); }); + it('runs transforms for layouts if they should be run', function () { + files.getLayoutPath.returns('/some/path'); + files.tryRequire.returns(fakeUgrades()); + db.put.returns(bluebird.resolve()); + + return fn(2, 1, 'site/_layouts/foo/instances/bar', _.cloneDeep(sampleData)) + .then(resp => expect(resp._version).to.equal(2)); + }); + it('logs an error and does not upgrade data if an upgrade fails', function () { const badUpgrade = sandbox.stub().throws(), cmptUri = 'site/_components/foo/instances/bar'; diff --git a/lib/services/uris.js b/lib/services/uris.js index 0cd334ee..8297cd96 100644 --- a/lib/services/uris.js +++ b/lib/services/uris.js @@ -2,21 +2,13 @@ const _ = require('lodash'), buf = require('./buffer'), - db = require('./db'), references = require('./references'), { getPrefix } = require('clayutils'), notifications = require('./notifications'), siteService = require('./sites'), - plugins = require('../plugins'), - bus = require('./bus'); - -/** - * @param {string} uri - * @returns {Promise} - */ -function get(uri) { - return db.get(uri); -} + bus = require('./bus'), + meta = require('./metadata'); +var db = require('./db'); /** * @param {string} uri @@ -36,7 +28,7 @@ function put(uri, body) { throw new Error('Client: Cannot point uri at propagating version, such as @published'); } - return db.put(uri, body).return(body); + return db.put(uri, body).then(() => body); } /** @@ -50,24 +42,22 @@ function put(uri, body) { * @returns {Promise} */ function del(uri, user) { - return get(uri).then(function (oldData) { - return db.del(uri).then(function () { + return db.get(uri).then(oldPageUri => { + return db.del(uri).then(() => { const prefix = getPrefix(uri), site = siteService.getSiteFromPrefix(prefix), - pageUrl = buf.decode(uri.split('/').pop()), - obj = { uri: oldData, url: pageUrl, user: user }; + pageUrl = buf.decode(uri.split('/').pop()); - // TODO: Clean this up in v7 - // Call the unpublish hook for plugins - plugins.executeHook('unpublish', { url: pageUrl, uri: oldData }); - plugins.executeHook('unpublishPage', obj); - bus.publish('unpublishPage', JSON.stringify(obj)); - - notifications.notify(site, 'unpublished', { url: pageUrl, uri: oldData }); - }).return(oldData); + bus.publish('unpublishPage', JSON.stringify({ uri: oldPageUri, url: pageUrl, user })); + notifications.notify(site, 'unpublished', { url: pageUrl, uri: oldPageUri }); + return meta.unpublishPage(oldPageUri, user) + .then(() => oldPageUri); + }); }); } -module.exports.get = get; module.exports.put = put; module.exports.del = del; + +// For testing +module.exports.setDb = mock => db = mock; diff --git a/lib/services/uris.test.js b/lib/services/uris.test.js index 02ce1f97..811960ee 100644 --- a/lib/services/uris.test.js +++ b/lib/services/uris.test.js @@ -1,25 +1,26 @@ 'use strict'; + const _ = require('lodash'), bluebird = require('bluebird'), - db = require('./db'), filename = __filename.split('/').pop().split('.').shift(), expect = require('chai').expect, sinon = require('sinon'), siteService = require('./sites'), notifications = require('./notifications'), - plugins = require('../plugins'), - lib = require('./' + filename); + meta = require('./metadata'), + lib = require('./' + filename), + storage = require('../../test/fixtures/mocks/storage'); describe(_.startCase(filename), function () { - let sandbox; + let sandbox, db; beforeEach(function () { sandbox = sinon.sandbox.create(); - sandbox.stub(db, 'get'); - sandbox.stub(db, 'del'); sandbox.stub(notifications, 'notify'); - sandbox.stub(plugins, 'executeHook'); sandbox.stub(siteService, 'getSiteFromPrefix'); + sandbox.stub(meta); + db = storage(); + lib.setDb(db); }); afterEach(function () { @@ -39,26 +40,13 @@ describe(_.startCase(filename), function () { db.del.returns(bluebird.resolve()); db.get.returns(bluebird.resolve(oldData)); siteService.getSiteFromPrefix.returns(site); + meta.unpublishPage.returns(Promise.resolve()); - return fn(uri).then(function () { + return fn(uri).then(() => { sinon.assert.calledWith(notifications.notify, site, 'unpublished', eventData); }); }); - it('plugin unpublish hook', function () { - const uri = 'something/_uris/bmljZXVybA==', - site = {a: 'b'}, - oldData = {url: 'niceurl'}; - - db.del.returns(bluebird.resolve()); - db.get.returns(bluebird.resolve(oldData)); - siteService.getSiteFromPrefix.returns(site); - - return fn(uri).then(function () { - sinon.assert.calledWith(plugins.executeHook, 'unpublish'); - }); - }); - it('deletes', function () { const uri = 'something/_uris/some-uri', site = {a: 'b'}, @@ -67,8 +55,9 @@ describe(_.startCase(filename), function () { db.del.returns(bluebird.resolve()); db.get.returns(bluebird.resolve(oldData)); siteService.getSiteFromPrefix.returns(site); + meta.unpublishPage.returns(Promise.resolve()); - return fn(uri).then(function () { + return fn(uri).then(() => { sinon.assert.calledWith(db.del, uri); }); }); @@ -81,6 +70,7 @@ describe(_.startCase(filename), function () { db.del.returns(bluebird.resolve()); db.get.returns(bluebird.resolve(oldData)); siteService.getSiteFromPrefix.returns(site); + meta.unpublishPage.returns(Promise.resolve()); return fn(uri).then(function (result) { expect(result).to.deep.equal(oldData); diff --git a/lib/services/users.js b/lib/services/users.js index b22c7550..1411db6d 100644 --- a/lib/services/users.js +++ b/lib/services/users.js @@ -6,8 +6,9 @@ 'use strict'; -const db = require('./db'), - buf = require('./buffer'); +const buf = require('./buffer'), + bus = require('./bus'); +var db = require('./db'); /** * encode username and provider to base64 @@ -45,8 +46,10 @@ function createUser(data) { uri += encode(username.toLowerCase(), provider); // Save to the DB - return db.put(uri, JSON.stringify(data)).then(function () { + return db.put(uri, JSON.stringify(data)).then(() => { data._ref = uri; + + bus.publish('saveUser', JSON.stringify({ key: uri, value: data })); return data; }); } @@ -55,3 +58,6 @@ function createUser(data) { module.exports.createUser = createUser; module.exports.encode = encode; module.exports.decode = decode; + +// For testing +module.exports.setDb = mock => db = mock; diff --git a/lib/services/users.test.js b/lib/services/users.test.js index fbb05594..757ffb5e 100644 --- a/lib/services/users.test.js +++ b/lib/services/users.test.js @@ -1,18 +1,19 @@ 'use strict'; const _ = require('lodash'), - db = require('./db'), filename = __filename.split('/').pop().split('.').shift(), expect = require('chai').expect, sinon = require('sinon'), lib = require('./' + filename), + storage = require('../../test/fixtures/mocks/storage'), buf = require('./buffer'); describe(_.startCase(filename), function () { - let sandbox; + let sandbox, db; beforeEach(function () { sandbox = sinon.sandbox.create(); - sandbox.stub(db, 'put'); + db = storage(); + lib.setDb(db); }); afterEach(function () { diff --git a/lib/setup.js b/lib/setup.js index 7614ad1b..855854ef 100644 --- a/lib/setup.js +++ b/lib/setup.js @@ -5,11 +5,12 @@ const bluebird = require('bluebird'), routes = require('./routes'), internalBootstrap = require('./bootstrap'), render = require('./render'), - amphoraPlugins = require('./plugins'), + amphoraPlugins = require('./services/plugins'), + db = require('./services/db'), bus = require('./services/bus'); bluebird.config({ - longStackTraces: true + longStackTraces: process.env.AMPHORA_LONG_STACKTRACES }); /** @@ -23,7 +24,6 @@ bluebird.config({ * @param {Array} [options.plugins] * @param {Array} [options.env] * @param {Boolean} [options.bootstrap] - * @param {Object} [options.cacheControl] * @returns {Promise} */ module.exports = function (options = {}) { @@ -33,27 +33,29 @@ module.exports = function (options = {}) { sessionStore, renderers, plugins = [], - cacheControl = {}, - bootstrap = true - } = options, router; // TODO: DOCUMENT RENDERERS PLUGINS, AND CACHE CONTROL + bootstrap = true, + storage = false + } = options, router; - // Init plugins - if (plugins.length) { - amphoraPlugins.registerPlugins(plugins); + if (!storage) { + throw new Error('A database integration was not supplied'); } if (process.env.REDIS_BUS_HOST) { bus.connect(); } - // init the router - router = routes(app, providers, sessionStore, cacheControl); - // if engines were passed in, send them to the renderer if (renderers) { render.registerRenderers(renderers); } - // look for bootstraps in components - return internalBootstrap(bootstrap).then(() => router); + // Make sure the storage module is run, then bootstrap + // all the default data and finally return the router + return storage.setup() + .then(() => db.registerStorage(storage)) + .then(() => amphoraPlugins.registerPlugins(plugins)) + .then(() => { router = routes(app, providers, sessionStore); }) + .then(() => internalBootstrap(bootstrap)) + .then(() => router); }; diff --git a/lib/setup.test.js b/lib/setup.test.js index 172134f9..ead81fc7 100644 --- a/lib/setup.test.js +++ b/lib/setup.test.js @@ -4,17 +4,20 @@ const _ = require('lodash'), filename = __filename.split('/').pop().split('.').shift(), lib = require('./' + filename), sinon = require('sinon'), - plugins = require('./plugins'), + { expect } = require('chai'), + storage = require('../test/fixtures/mocks/storage'), + plugins = require('./services/plugins'), render = require('./render'), bus = require('./services/bus'); describe(_.startCase(filename), function () { - let sandbox; + let sandbox, db; beforeEach(function () { sandbox = sinon.sandbox.create(); sandbox.stub(plugins, 'registerPlugins'); sandbox.stub(render, 'registerRenderers'); + db = storage(); sandbox.stub(bus); }); @@ -35,19 +38,23 @@ describe(_.startCase(filename), function () { } it('sets up', function () { - return lib(); + return lib({ storage: db }); + }); + + it('throws an error if no storage object is assigned', function () { + expect(() => lib()).to.throw(); }); it('registers plugins', function () { const plugin = sandbox.stub(pluginMock()); - return lib({ plugins: [plugin] }).then(function () { + return lib({ plugins: [plugin], storage: db }).then(function () { sinon.assert.calledOnce(plugins.registerPlugins); }); }); it('registers renderers', function () { - return lib({ renderers: { default: 'html', html: _.noop } }).then(function () { + return lib({ renderers: { default: 'html', html: _.noop }, storage: db }).then(function () { sinon.assert.calledOnce(render.registerRenderers); }); }); @@ -55,6 +62,6 @@ describe(_.startCase(filename), function () { it('initializes the bus if env vars are set', function () { process.env.REDIS_BUS_HOST = 'redis://localhost:6379'; - return lib().then(() => sinon.assert.calledOnce(bus.connect)); + return lib({ storage: db }).then(() => sinon.assert.calledOnce(bus.connect)); }); }); diff --git a/lib/utils/schema.js b/lib/utils/schema.js index a1737354..791a83cd 100644 --- a/lib/utils/schema.js +++ b/lib/utils/schema.js @@ -5,10 +5,10 @@ const bluebird = require('bluebird'), schema = require('../schema'), { getComponentName, getLayoutName, isComponent } = require('clayutils'); - /** - * @param {string} uri - * @returns {Promise} - */ +/** + * @param {String} uri + * @returns {Promise} + */ function getSchema(uri) { return bluebird.try(() => { const cmpt = isComponent(uri), diff --git a/package-lock.json b/package-lock.json index 68225877..c6ea62a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,27 @@ { "name": "amphora", - "version": "6.9.0", + "version": "7.0.0-0", "lockfileVersion": 1, "requires": true, "dependencies": { "@sinonjs/formatio": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz", + "resolved": "http://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz", "integrity": "sha512-ls6CAMA6/5gG+O/IdsBcblvnd8qcO/l1TYoNeAzp3wcISOxlPXQEus0mLcdwazEkWjaBdaJ3TaxmNgCLWwvWzg==", "dev": true, "requires": { "samsam": "1.3.0" } }, + "@sinonjs/samsam": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-2.1.0.tgz", + "integrity": "sha512-5x2kFgJYupaF1ns/RmharQ90lQkd2ELS8A9X0ymkAAdemYHGtI2KiUHG8nX2WU0T1qgnOU5YMqnBM2V7NUanNw==", + "dev": true, + "requires": { + "array-from": "2.1.1" + } + }, "abbrev": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", @@ -23,6 +32,7 @@ "version": "2.6.3", "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-2.6.3.tgz", "integrity": "sha512-2++wDf/DYqkPR3o5tbfdhF96EfMApo1GpPfzOsR/ZYXdkSmELlvOOEAl9iKkRsktMPHdGjO4rtkBpf2I7TiTeA==", + "dev": true, "requires": { "xtend": "4.0.1" } @@ -32,14 +42,14 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=", "requires": { - "mime-types": "2.1.19", + "mime-types": "2.1.20", "negotiator": "0.6.1" } }, "acorn": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.1.tgz", - "integrity": "sha512-d+nbxBUGKg7Arpsvbnlq61mc12ek3EY8EQldM3GPAhWJ1UVxC6TDGbIvUMNU6obBX3i1+ptCIzV4vq0gFPEGVQ==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", + "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==", "dev": true }, "acorn-jsx": { @@ -53,7 +63,7 @@ "dependencies": { "acorn": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "resolved": "http://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", "dev": true } @@ -77,29 +87,21 @@ "integrity": "sha1-YXmX/F9gV2iUxDX5QNgZ4TW4B2I=", "dev": true }, - "align-text": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", - "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", - "requires": { - "kind-of": "3.2.2", - "longest": "1.0.1", - "repeat-string": "1.6.1" - } - }, "amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=" + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true, + "optional": true }, "amphora-fs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/amphora-fs/-/amphora-fs-1.0.1.tgz", - "integrity": "sha512-ZYCvWkgQL6ooDfYIh5wM7+kBUmPObwBm3Wz6G02HBKL5jZWKJWciIkNvuQPTrkcHYXBFBeKmxxMjAqCUPSFS/Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/amphora-fs/-/amphora-fs-1.0.2.tgz", + "integrity": "sha512-j/RrT2iWxs6VH7bP4l7c91JJNM54ZYf1Nr/7MR9cw0wmhnee/3a/unuLi4yeb+b1ssIsfzlP8/bvtyFhUq8gog==", "requires": { "clayutils": "2.6.0", "js-yaml": "3.12.0", - "lodash": "4.17.10", + "lodash": "4.17.11", "template2env": "1.0.4" } }, @@ -110,9 +112,9 @@ "dev": true }, "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true }, "ansi-styles": { @@ -120,7 +122,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "requires": { - "color-convert": "1.9.2" + "color-convert": "1.9.3" } }, "argparse": { @@ -136,6 +138,12 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, + "array-from": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", + "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=", + "dev": true + }, "array-union": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", @@ -174,9 +182,12 @@ "dev": true }, "async": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", - "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "requires": { + "lodash": "4.17.11" + } }, "asynckit": { "version": "0.4.0", @@ -222,7 +233,7 @@ }, "basic-auth": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/basic-auth/-/basic-auth-1.1.0.tgz", "integrity": "sha1-RSIe5Cn37h5QNb4/UVM/HN/SmIQ=" }, "bcrypt-pbkdf": { @@ -241,9 +252,9 @@ "integrity": "sha1-WCaQDP73q680JccuTUZN5Qm4wuw=" }, "bluebird": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", - "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.2.tgz", + "integrity": "sha512-dhHTWMI7kMx5whMQntl7Vr9C6BvV10lFXDAasnqnrMYhXVCzzk6IO9Fo2L75jXHT07WrOngL1WDXOp+yYS91Yg==" }, "body-parser": { "version": "1.18.3", @@ -334,16 +345,6 @@ "integrity": "sha1-cVuW6phBWTzDMGeSP17GDr2k99c=", "dev": true }, - "center-align": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", - "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", - "optional": true, - "requires": { - "align-text": "0.1.4", - "lazy-cache": "1.0.4" - } - }, "chai": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", @@ -357,7 +358,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "dev": true, "requires": { @@ -368,27 +369,12 @@ "supports-color": "2.0.0" }, "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - }, "ansi-styles": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", "dev": true }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, - "requires": { - "ansi-regex": "2.1.1" - } - }, "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", @@ -423,8 +409,8 @@ "resolved": "https://registry.npmjs.org/clayutils/-/clayutils-2.6.0.tgz", "integrity": "sha512-xH21a4dx95o1y1Fz8vk733EUnsrnkTuZw4hz46Koqs9O3KTs9OhnIvoSqARtaWBGqLY1Ohq3WQz4qXSrkTylVg==", "requires": { - "glob": "7.1.2", - "lodash": "4.17.10", + "glob": "7.1.3", + "lodash": "4.17.11", "nymag-fs": "1.0.1" } }, @@ -455,22 +441,22 @@ "dev": true }, "color-convert": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.2.tgz", - "integrity": "sha512-3NUJZdhMhcdPn8vJ9v2UQJoH0qqoGUkYTgFEPZaPjEtwmmKUfNV46zZmgB2M5M4DCEQHMaCfWHCxiBflLm04Tg==", + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "requires": { - "color-name": "1.1.1" + "color-name": "1.1.3" } }, "color-name": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.1.tgz", - "integrity": "sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok=" + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "combined-stream": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", - "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz", + "integrity": "sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==", "dev": true, "requires": { "delayed-stream": "1.0.0" @@ -479,8 +465,7 @@ "commander": { "version": "2.17.1", "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", - "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", - "dev": true + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==" }, "component-emitter": { "version": "1.2.1", @@ -574,6 +559,12 @@ "argparse": "1.0.10", "esprima": "2.7.3" } + }, + "minimist": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true } } }, @@ -591,6 +582,18 @@ "lru-cache": "4.1.3", "shebang-command": "1.2.0", "which": "1.3.1" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", + "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", + "dev": true, + "requires": { + "pseudomap": "1.0.2", + "yallist": "2.1.2" + } + } } }, "cryptiles": { @@ -628,12 +631,6 @@ "ms": "2.0.0" } }, - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", - "optional": true - }, "deep-eql": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", @@ -661,6 +658,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-1.2.2.tgz", "integrity": "sha512-uukrWD2bguRtXilKt6cAWKyoXrTSMo5m7crUdLfWQmu8kIm88w3QZoUL+6nhpfKVmhHANER6Re3sKoNoZ3IKMA==", + "dev": true, "requires": { "abstract-leveldown": "2.6.3" } @@ -678,14 +676,6 @@ "pify": "2.3.0", "pinkie-promise": "2.0.1", "rimraf": "2.6.2" - }, - "dependencies": { - "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "dev": true - } } }, "delayed-stream": { @@ -730,7 +720,7 @@ "integrity": "sha1-3JObTT4GIM/gwc2APQ0tftBP/QQ=", "optional": true, "requires": { - "nan": "2.10.0" + "nan": "2.11.0" } }, "ecc-jsbn": { @@ -774,6 +764,7 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==", + "dev": true, "requires": { "prr": "1.0.1" } @@ -827,7 +818,7 @@ }, "eslint": { "version": "4.19.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz", + "resolved": "http://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz", "integrity": "sha512-bT3/1x1EbZB7phzYu7vCr1v3ONuzDtX8WjuM9c0iYxe+cq+pwcKEoQjl7zd3RpC6YOLgnSy3cTN58M2jcoPDIQ==", "dev": true, "requires": { @@ -836,7 +827,7 @@ "chalk": "2.4.1", "concat-stream": "1.6.2", "cross-spawn": "5.1.0", - "debug": "3.1.0", + "debug": "3.2.5", "doctrine": "2.1.0", "eslint-scope": "3.7.3", "eslint-visitor-keys": "1.0.0", @@ -845,7 +836,7 @@ "esutils": "2.0.2", "file-entry-cache": "2.0.0", "functional-red-black-tree": "1.0.1", - "glob": "7.1.2", + "glob": "7.1.3", "globals": "11.7.0", "ignore": "3.3.10", "imurmurhash": "0.1.4", @@ -854,7 +845,7 @@ "js-yaml": "3.12.0", "json-stable-stringify-without-jsonify": "1.0.1", "levn": "0.3.0", - "lodash": "4.17.10", + "lodash": "4.17.11", "minimatch": "3.0.4", "mkdirp": "0.5.1", "natural-compare": "1.4.0", @@ -864,13 +855,19 @@ "progress": "2.0.0", "regexpp": "1.1.0", "require-uncached": "1.0.3", - "semver": "5.5.0", + "semver": "5.5.1", "strip-ansi": "4.0.0", "strip-json-comments": "2.0.1", "table": "4.0.2", "text-table": "0.2.0" }, "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, "chalk": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", @@ -879,16 +876,31 @@ "requires": { "ansi-styles": "3.2.1", "escape-string-regexp": "1.0.5", - "supports-color": "5.4.0" + "supports-color": "5.5.0" } }, "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", "dev": true, "requires": { - "ms": "2.0.0" + "ms": "2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "3.0.0" } } } @@ -915,7 +927,7 @@ "integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==", "dev": true, "requires": { - "acorn": "5.7.1", + "acorn": "5.7.3", "acorn-jsx": "3.0.1" } }, @@ -966,7 +978,7 @@ }, "express": { "version": "4.16.3", - "resolved": "https://registry.npmjs.org/express/-/express-4.16.3.tgz", + "resolved": "http://registry.npmjs.org/express/-/express-4.16.3.tgz", "integrity": "sha1-avilAjUNsyRuzEvs9rWjTSL37VM=", "requires": { "accepts": "1.3.5", @@ -1106,7 +1118,7 @@ }, "external-editor": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", + "resolved": "http://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", "integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==", "dev": true, "requires": { @@ -1223,8 +1235,8 @@ "dev": true, "requires": { "asynckit": "0.4.0", - "combined-stream": "1.0.6", - "mime-types": "2.1.19" + "combined-stream": "1.0.7", + "mime-types": "2.1.20" } }, "formidable": { @@ -1251,13 +1263,17 @@ "functional-red-black-tree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=" + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true }, "generate-function": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz", - "integrity": "sha1-aFj+fAlpt9TpCTM3ZHrHn2DfvnQ=", - "dev": true + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dev": true, + "requires": { + "is-property": "1.0.2" + } }, "generate-object-property": { "version": "1.2.0", @@ -1278,9 +1294,9 @@ } }, "glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "requires": { "fs.realpath": "1.0.0", "inflight": "1.0.6", @@ -1304,18 +1320,10 @@ "requires": { "array-union": "1.0.2", "arrify": "1.0.1", - "glob": "7.1.2", + "glob": "7.1.3", "object-assign": "4.1.1", "pify": "2.3.0", "pinkie-promise": "2.0.1" - }, - "dependencies": { - "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "dev": true - } } }, "graceful-fs": { @@ -1337,14 +1345,14 @@ "dev": true }, "handlebars": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", - "integrity": "sha1-Ywo13+ApS8KB7a5v/F0yn8eYLcw=", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.12.tgz", + "integrity": "sha512-RhmTekP+FZL+XNhwS1Wf+bTTZpdLougwt5pcgA1tuz6Jcx0fpH/7z0qd71RKnZHBCxIRBHfBOnio4gViPemNzA==", "requires": { - "async": "1.5.2", + "async": "2.6.1", "optimist": "0.6.1", - "source-map": "0.4.4", - "uglify-js": "2.8.29" + "source-map": "0.6.1", + "uglify-js": "3.4.9" } }, "har-validator": { @@ -1366,14 +1374,6 @@ "dev": true, "requires": { "ansi-regex": "2.1.1" - }, - "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - } } }, "has-flag": { @@ -1415,7 +1415,7 @@ }, "http-errors": { "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "resolved": "http://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", "requires": { "depd": "1.1.2", @@ -1460,7 +1460,8 @@ "immediate": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.2.3.tgz", - "integrity": "sha1-0UD6j2FGWb1lQSMwl92qwlzdmRw=" + "integrity": "sha1-0UD6j2FGWb1lQSMwl92qwlzdmRw=", + "dev": true }, "imurmurhash": { "version": "0.1.4", @@ -1494,7 +1495,7 @@ "cli-width": "2.2.0", "external-editor": "2.2.0", "figures": "2.0.0", - "lodash": "4.17.10", + "lodash": "4.17.11", "mute-stream": "0.0.7", "run-async": "2.3.0", "rx-lite": "4.0.8", @@ -1504,6 +1505,12 @@ "through": "2.3.8" }, "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, "chalk": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", @@ -1512,7 +1519,16 @@ "requires": { "ansi-styles": "3.2.1", "escape-string-regexp": "1.0.5", - "supports-color": "5.4.0" + "supports-color": "5.5.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "3.0.0" } } } @@ -1523,7 +1539,7 @@ "integrity": "sha512-KDio3eKM4zZWRPWlcM26E4Dcbj1bH6pPLNuCHJwKucklsEVMXT0axh5ctPaETbkPIBLRk910qKOEQoXSFkn+dw==", "requires": { "cluster-key-slot": "1.0.12", - "debug": "3.1.0", + "debug": "3.2.5", "denque": "1.3.0", "flexbuffer": "0.0.6", "lodash.bind": "4.2.1", @@ -1546,30 +1562,17 @@ }, "dependencies": { "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", "requires": { - "ms": "2.0.0" + "ms": "2.1.1" } }, - "lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" - }, - "lodash.values": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.values/-/lodash.values-4.3.0.tgz", - "integrity": "sha1-o6bCsOvsxcLLocF+bmIP6BtT00c=" - }, - "redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", - "requires": { - "redis-errors": "1.2.0" - } + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" } } }, @@ -1578,11 +1581,6 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.8.0.tgz", "integrity": "sha1-6qM9bd16zo9/b+DJygRA5wZzix4=" }, - "is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" - }, "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", @@ -1601,7 +1599,7 @@ "integrity": "sha512-mG0f/unGX1HZ5ep4uhRaPOS8EkAY8/j6mDRMJrutq4CqhoJWYp7qAlonIPy3TV7p3ju4TK9fo/PbnoksWmsp5Q==", "dev": true, "requires": { - "generate-function": "2.0.0", + "generate-function": "2.3.1", "generate-object-property": "1.2.0", "is-my-ip-valid": "1.0.0", "jsonpointer": "4.0.1", @@ -1689,7 +1687,7 @@ "escodegen": "1.8.1", "esprima": "2.7.3", "glob": "5.0.15", - "handlebars": "4.0.11", + "handlebars": "4.0.12", "js-yaml": "3.12.0", "mkdirp": "0.5.1", "nopt": "3.0.6", @@ -1700,6 +1698,12 @@ "wordwrap": "1.0.0" }, "dependencies": { + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, "esprima": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", @@ -1821,25 +1825,11 @@ } }, "just-extend": { - "version": "1.1.27", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-1.1.27.tgz", - "integrity": "sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-3.0.0.tgz", + "integrity": "sha512-Fu3T6pKBuxjWT/p4DkqGHFRsysc8OauWr4ZRTY9dIx07Y9O0RkoR5jcv28aeD1vuAwhm3nLkDurwLXoALp4DpQ==", "dev": true }, - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "requires": { - "is-buffer": "1.1.6" - } - }, - "lazy-cache": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", - "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", - "optional": true - }, "lcov-parse": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-0.0.10.tgz", @@ -1869,16 +1859,6 @@ "bcryptjs": "2.3.0", "ldapjs": "1.0.2", "lru-cache": "3.2.0" - }, - "dependencies": { - "lru-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-3.2.0.tgz", - "integrity": "sha1-cXibO39Tmb7IVl3aOKow0qCX7+4=", - "requires": { - "pseudomap": "1.0.2" - } - } } }, "ldapjs": { @@ -1901,12 +1881,14 @@ "level-codec": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-7.0.1.tgz", - "integrity": "sha512-Ua/R9B9r3RasXdRmOtd+t9TCOEIIlts+TN/7XTT2unhDaL6sJn83S3rUyljbr6lVtw49N3/yA0HHjpV6Kzb2aQ==" + "integrity": "sha512-Ua/R9B9r3RasXdRmOtd+t9TCOEIIlts+TN/7XTT2unhDaL6sJn83S3rUyljbr6lVtw49N3/yA0HHjpV6Kzb2aQ==", + "dev": true }, "level-errors": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/level-errors/-/level-errors-1.0.5.tgz", "integrity": "sha512-/cLUpQduF6bNrWuAC4pwtUKA5t669pCsCi2XbmojG2tFeOr9j6ShtdDCtFFQO1DRt+EVZhx9gPzP9G2bUaG4ig==", + "dev": true, "requires": { "errno": "0.1.7" } @@ -1915,6 +1897,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/level-iterator-stream/-/level-iterator-stream-1.3.1.tgz", "integrity": "sha1-5Dt4sagUPm+pek9IXrjqUwNS8u0=", + "dev": true, "requires": { "inherits": "2.0.3", "level-errors": "1.0.5", @@ -1925,12 +1908,14 @@ "isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true }, "readable-stream": { "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "dev": true, "requires": { "core-util-is": "1.0.2", "inherits": "2.0.3", @@ -1941,7 +1926,8 @@ "string_decoder": { "version": "0.10.31", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true } } }, @@ -1949,6 +1935,7 @@ "version": "1.3.9", "resolved": "https://registry.npmjs.org/levelup/-/levelup-1.3.9.tgz", "integrity": "sha512-VVGHfKIlmw8w1XqpGOAGwq6sZm2WwWLmlDcULkKWQXEA5EopA8OBNJ2Ck2v6bdk8HeEZSbCSEgzXadyQFm76sQ==", + "dev": true, "requires": { "deferred-leveldown": "1.2.2", "level-codec": "7.0.1", @@ -1962,7 +1949,8 @@ "semver": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", - "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==" + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", + "dev": true } } }, @@ -1977,9 +1965,9 @@ } }, "lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==" + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" }, "lodash._baseassign": { "version": "3.2.0", @@ -2041,6 +2029,11 @@ "lodash._isiterateecall": "3.0.9" } }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" + }, "lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", @@ -2110,6 +2103,11 @@ "resolved": "https://registry.npmjs.org/lodash.shuffle/-/lodash.shuffle-4.2.0.tgz", "integrity": "sha1-FFtQU8+HX29cKjP0i26ZSMbse0s=" }, + "lodash.values": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.values/-/lodash.values-4.3.0.tgz", + "integrity": "sha1-o6bCsOvsxcLLocF+bmIP6BtT00c=" + }, "log-driver": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.5.tgz", @@ -2117,30 +2115,24 @@ "dev": true }, "lolex": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.1.tgz", - "integrity": "sha512-Oo2Si3RMKV3+lV5MsSWplDQFoTClz/24S0MMHYcgGWWmFXr6TMlqcqk/l1GtH+d5wLBwNRiqGnwDRMirtFalJw==", + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.5.tgz", + "integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==", "dev": true }, - "longest": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", - "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" - }, "lru-cache": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", - "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", - "dev": true, + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-3.2.0.tgz", + "integrity": "sha1-cXibO39Tmb7IVl3aOKow0qCX7+4=", "requires": { - "pseudomap": "1.0.2", - "yallist": "2.1.2" + "pseudomap": "1.0.2" } }, "ltgt": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", - "integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=" + "integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=", + "dev": true }, "media-typer": { "version": "0.3.0", @@ -2151,6 +2143,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/memdown/-/memdown-1.4.1.tgz", "integrity": "sha1-tOThkhdGZP+65BNhqlAPMRnv4hU=", + "dev": true, "requires": { "abstract-leveldown": "2.7.2", "functional-red-black-tree": "1.0.1", @@ -2164,6 +2157,7 @@ "version": "2.7.2", "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-2.7.2.tgz", "integrity": "sha512-+OVvxH2rHVEhWLdbudP6p0+dNMXu8JA1CbhP19T8paTYAcX7oJ4OVjT+ZUVpv7mITxXHqDMej+GdqXBmXkw09w==", + "dev": true, "requires": { "xtend": "4.0.1" } @@ -2186,16 +2180,16 @@ "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" }, "mime-db": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.35.0.tgz", - "integrity": "sha512-JWT/IcCTsB0Io3AhWUMjRqucrHSPsSf2xKLaRldJVULioggvkJvggZ3VXNNSRkCddE6D+BUI4HEIZIA2OjwIvg==" + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.36.0.tgz", + "integrity": "sha512-L+xvyD9MkoYMXb1jAmzI/lWYAxAMCPvIBSWur0PZ5nOf5euahRLVqH//FKW9mWp2lkqUgYiXPgkzfMUFi4zVDw==" }, "mime-types": { - "version": "2.1.19", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.19.tgz", - "integrity": "sha512-P1tKYHVSZ6uFo26mtnve4HQFE3koh1UWVkp8YUC+ESBHe945xWSoXuHHiGarDqcEZ+whpCDnlNw5LON0kLo+sw==", + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.20.tgz", + "integrity": "sha512-HrkrPaP9vGuWbLK1B1FfgAkbqNjIuy4eHlIYnFi7kamZyLLrGlo2mpcx0bBmNpKqBtYtAfGbodDddIgddSJC2A==", "requires": { - "mime-db": "1.35.0" + "mime-db": "1.36.0" } }, "mimic-fn": { @@ -2213,14 +2207,13 @@ } }, "minimist": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", - "dev": true + "version": "0.0.10", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" }, "mkdirp": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "requires": { "minimist": "0.0.8" @@ -2228,7 +2221,7 @@ "dependencies": { "minimist": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" } } @@ -2255,7 +2248,7 @@ "dependencies": { "commander": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", + "resolved": "http://registry.npmjs.org/commander/-/commander-2.9.0.tgz", "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", "dev": true, "requires": { @@ -2345,7 +2338,7 @@ }, "rimraf": { "version": "2.4.5", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "resolved": "http://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=", "optional": true, "requires": { @@ -2355,9 +2348,9 @@ } }, "nan": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz", - "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.0.tgz", + "integrity": "sha512-F4miItu2rGnV2ySkXOQoA8FKz/SR2Q2sWP0sbTxNxz/tuokeC8WxOhPMcwi0qIyGtVn/rrSeLbvVkznqCdwYnw==", "optional": true }, "natural-compare": { @@ -2368,7 +2361,7 @@ }, "ncp": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "resolved": "http://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", "optional": true }, @@ -2378,18 +2371,27 @@ "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" }, "nise": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.3.tgz", - "integrity": "sha512-cg44dkGHutAY+VmftgB1gHvLWxFl2vwYdF8WpbceYicQwylESRJiAAKgCRJntdoEbMiUzywkZEUzjoDWH0JwKA==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.5.tgz", + "integrity": "sha512-OHRVvdxKgwZELf2DTgsJEIA4MOq8XWvpSUzoOXyxJ2mY0mMENWC66+70AShLR2z05B1dzrzWlUQJmJERlOUpZw==", "dev": true, "requires": { - "@sinonjs/formatio": "2.0.0", - "just-extend": "1.1.27", - "lolex": "2.7.1", + "@sinonjs/formatio": "3.0.0", + "just-extend": "3.0.0", + "lolex": "2.7.5", "path-to-regexp": "1.7.0", "text-encoding": "0.6.4" }, "dependencies": { + "@sinonjs/formatio": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.0.0.tgz", + "integrity": "sha512-vdjoYLDptCgvtJs57ULshak3iJe4NW3sJ3g36xVDGff5AE8P30S6A093EIEPjdi2noGhfuNOEkbxt3J3awFW1w==", + "dev": true, + "requires": { + "@sinonjs/samsam": "2.1.0" + } + }, "isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", @@ -2435,9 +2437,9 @@ "resolved": "https://registry.npmjs.org/nymag-fs/-/nymag-fs-1.0.1.tgz", "integrity": "sha1-STkdL5fktYjNgSeImGqyH61afD4=", "requires": { - "glob": "7.1.2", + "glob": "7.1.3", "js-yaml": "3.12.0", - "lodash": "4.17.10" + "lodash": "4.17.11" } }, "oauth": { @@ -2494,13 +2496,6 @@ "requires": { "minimist": "0.0.10", "wordwrap": "0.0.3" - }, - "dependencies": { - "minimist": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", - "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" - } } }, "optionator": { @@ -2631,7 +2626,7 @@ }, "passport-slack": { "version": "0.0.6", - "resolved": "https://registry.npmjs.org/passport-slack/-/passport-slack-0.0.6.tgz", + "resolved": "http://registry.npmjs.org/passport-slack/-/passport-slack-0.0.6.tgz", "integrity": "sha1-eKtc53dUzWadVy/De3A/oCbCiGQ=", "requires": { "passport-oauth": "0.1.15", @@ -2673,6 +2668,12 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, "pinkie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", @@ -2697,7 +2698,7 @@ "fast-json-parse": "1.0.3", "fast-safe-stringify": "1.2.3", "flatstr": "1.0.8", - "pino-std-serializers": "2.2.0", + "pino-std-serializers": "2.2.1", "pump": "3.0.0", "quick-format-unescaped": "1.1.2", "split2": "2.2.0" @@ -2710,15 +2711,15 @@ "requires": { "ansi-styles": "3.2.1", "escape-string-regexp": "1.0.5", - "supports-color": "5.4.0" + "supports-color": "5.5.0" } } } }, "pino-std-serializers": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-2.2.0.tgz", - "integrity": "sha512-Ef95yX2/cUb5knEmHCpvkfrvjWBx0CTcBwB3WAneX8o0WpEf8y+lmR/XMkgAbJ/Ak2mHbo0eL5ANy8qDqpH1xw==" + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-2.2.1.tgz", + "integrity": "sha512-QqL7kkF7eMCpFG4hpZD8UPQga/kxkkh3E62HzMzTIL4OQyijyisAnBL8msBEAml8xcb/ioGhH7UUzGxuHqczhQ==" }, "pkginfo": { "version": "0.2.3", @@ -2765,7 +2766,8 @@ "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=" + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "dev": true }, "pseudomap": { "version": "1.0.2", @@ -2823,7 +2825,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "1.0.2", @@ -2845,27 +2847,30 @@ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "1.2.0" + } + }, "regexpp": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-1.1.0.tgz", "integrity": "sha512-LOPw8FpgdQF9etWMaAfG/WRthIdXJGYp4mJ2Jgn/2lpkbod9jPn0t9UqN7AxBOKNfzRbYyVfgc7Vk4t/MpnXgw==", "dev": true }, - "repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" - }, "request": { "version": "2.79.0", - "resolved": "https://registry.npmjs.org/request/-/request-2.79.0.tgz", + "resolved": "http://registry.npmjs.org/request/-/request-2.79.0.tgz", "integrity": "sha1-Tf5b9r6LjNw3/Pk+BLZVd3InEN4=", "dev": true, "requires": { "aws-sign2": "0.6.0", "aws4": "1.8.0", "caseless": "0.11.0", - "combined-stream": "1.0.6", + "combined-stream": "1.0.7", "extend": "3.0.2", "forever-agent": "0.6.1", "form-data": "2.1.4", @@ -2875,7 +2880,7 @@ "is-typedarray": "1.0.0", "isstream": "0.1.2", "json-stringify-safe": "5.0.1", - "mime-types": "2.1.19", + "mime-types": "2.1.20", "oauth-sign": "0.8.2", "qs": "6.3.2", "stringstream": "0.0.6", @@ -2924,22 +2929,13 @@ "signal-exit": "3.0.2" } }, - "right-align": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", - "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", - "optional": true, - "requires": { - "align-text": "0.1.4" - } - }, "rimraf": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", "dev": true, "requires": { - "glob": "7.1.2" + "glob": "7.1.3" } }, "run-async": { @@ -2989,9 +2985,9 @@ "dev": true }, "semver": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", - "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.1.tgz", + "integrity": "sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw==", "dev": true }, "send": { @@ -3060,16 +3056,16 @@ }, "sinon": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-4.5.0.tgz", + "resolved": "http://registry.npmjs.org/sinon/-/sinon-4.5.0.tgz", "integrity": "sha512-trdx+mB0VBBgoYucy6a9L7/jfQOmvGeaKZT4OOJ+lPAtI8623xyGr8wLiE4eojzBS8G9yXbhx42GHUOVLr4X2w==", "dev": true, "requires": { "@sinonjs/formatio": "2.0.0", "diff": "3.2.0", "lodash.get": "4.4.2", - "lolex": "2.7.1", - "nise": "1.4.3", - "supports-color": "5.4.0", + "lolex": "2.7.5", + "nise": "1.4.5", + "supports-color": "5.5.0", "type-detect": "4.0.8" }, "dependencies": { @@ -3100,12 +3096,9 @@ } }, "source-map": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", - "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", - "requires": { - "amdefine": "1.0.1" - } + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" }, "split2": { "version": "2.2.0", @@ -3155,6 +3148,23 @@ "requires": { "is-fullwidth-code-point": "2.0.0", "strip-ansi": "4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "3.0.0" + } + } } }, "string_decoder": { @@ -3172,12 +3182,12 @@ "dev": true }, "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "requires": { - "ansi-regex": "3.0.0" + "ansi-regex": "2.1.1" } }, "strip-json-comments": { @@ -3187,14 +3197,14 @@ "dev": true }, "superagent": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.2.tgz", - "integrity": "sha512-gVH4QfYHcY3P0f/BZzavLreHW3T1v7hG9B+hpMQotGQqurOvhv87GcMCd6LWySmBuf+BDR44TQd0aISjVHLeNQ==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", "dev": true, "requires": { "component-emitter": "1.2.1", "cookiejar": "2.1.2", - "debug": "3.1.0", + "debug": "3.2.5", "extend": "3.0.2", "form-data": "2.3.2", "formidable": "1.2.1", @@ -3204,13 +3214,22 @@ "readable-stream": "2.3.6" }, "dependencies": { + "combined-stream": { + "version": "1.0.6", + "resolved": "http://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "dev": true, + "requires": { + "delayed-stream": "1.0.0" + } + }, "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", "dev": true, "requires": { - "ms": "2.0.0" + "ms": "2.1.1" } }, "form-data": { @@ -3221,25 +3240,31 @@ "requires": { "asynckit": "0.4.0", "combined-stream": "1.0.6", - "mime-types": "2.1.19" + "mime-types": "2.1.20" } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true } } }, "supertest": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-3.1.0.tgz", - "integrity": "sha512-O44AMnmJqx294uJQjfUmEyYOg7d9mylNFsMw/Wkz4evKd1njyPrtCN+U6ZIC7sKtfEVQhfTqFFijlXx8KP/Czw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-3.3.0.tgz", + "integrity": "sha512-dMQSzYdaZRSANH5LL8kX3UpgK9G1LRh/jnggs/TI0W2Sz7rkMx9Y48uia3K9NgcaWEV28tYkBnXE4tiFC77ygQ==", "dev": true, "requires": { "methods": "1.1.2", - "superagent": "3.8.2" + "superagent": "3.8.3" } }, "supports-color": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", - "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "requires": { "has-flag": "3.0.0" } @@ -3253,7 +3278,7 @@ "ajv": "5.5.2", "ajv-keywords": "2.1.1", "chalk": "2.4.1", - "lodash": "4.17.10", + "lodash": "4.17.11", "slice-ansi": "1.0.0", "string-width": "2.1.1" }, @@ -3266,7 +3291,7 @@ "requires": { "ansi-styles": "3.2.1", "escape-string-regexp": "1.0.5", - "supports-color": "5.4.0" + "supports-color": "5.5.0" } } } @@ -3290,7 +3315,7 @@ }, "through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, @@ -3373,7 +3398,7 @@ "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==", "requires": { "media-typer": "0.3.0", - "mime-types": "2.1.19" + "mime-types": "2.1.20" } }, "typedarray": { @@ -3383,65 +3408,15 @@ "dev": true }, "uglify-js": { - "version": "2.8.29", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", - "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz", + "integrity": "sha512-8CJsbKOtEbnJsTyv6LE6m6ZKniqMiFWmm9sRbopbkGs3gMPPfd3Fh8iIA4Ykv5MgaTbqHr4BaoGLJLZNhsrW1Q==", "optional": true, "requires": { - "source-map": "0.5.7", - "uglify-to-browserify": "1.0.2", - "yargs": "3.10.0" - }, - "dependencies": { - "camelcase": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", - "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", - "optional": true - }, - "cliui": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", - "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", - "optional": true, - "requires": { - "center-align": "0.1.3", - "right-align": "0.1.3", - "wordwrap": "0.0.2" - } - }, - "source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", - "optional": true - }, - "wordwrap": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", - "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", - "optional": true - }, - "yargs": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", - "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", - "optional": true, - "requires": { - "camelcase": "1.2.1", - "cliui": "2.1.0", - "decamelize": "1.2.0", - "window-size": "0.1.0" - } - } + "commander": "2.17.1", + "source-map": "0.6.1" } }, - "uglify-to-browserify": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", - "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", - "optional": true - }, "uid-safe": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", @@ -3523,12 +3498,6 @@ "isexe": "2.0.0" } }, - "window-size": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", - "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", - "optional": true - }, "wordwrap": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", diff --git a/package.json b/package.json index a562caf9..fefdd4ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amphora", - "version": "6.9.0", + "version": "7.0.0-0", "description": "An API mixin for Express that saves, publishes and composes data with the key-value store of your choice.", "main": "index.js", "scripts": { @@ -21,7 +21,7 @@ "bluebird": "^3.5.1", "body-parser": "^1.12", "clay-log": "^1.2.0", - "clayutils": "^2.1.0", + "clayutils": "^2.5.0", "cuid": "^1.2.5", "eventify": "^2.0.0", "express": "^4.15.3", @@ -32,9 +32,7 @@ "highland": "^2.11.1", "ioredis": "^4.0.0", "js-yaml": "^3.2.0", - "levelup": "^1.3.9", "lodash": "^4.14.2", - "memdown": "^1.4.0", "node-fetch": "^1.3.0", "passport": "^0.3.2", "passport-google-oauth": "^1.0.0", @@ -53,6 +51,8 @@ "coveralls": "^2.11.2", "eslint": "^4.3.0", "istanbul": "^0.4.0", + "levelup": "^1.3.9", + "memdown": "^1.4.0", "mocha": "^3.5.0", "rimraf": "^2.6.2", "sinon": "^4.4.8", diff --git a/test/api/_components/get.js b/test/api/_components/get.js index d8c299f1..843cd4c2 100644 --- a/test/api/_components/get.js +++ b/test/api/_components/get.js @@ -13,7 +13,6 @@ describe(endpointName, function () { acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)), data = { name: 'Manny', species: 'cat' }, deepData = { d: 'e' }, - // todo: Stop putting internal information into something we're going to open-source componentList = ['clay-c5', 'clay-c3', 'clay-c4'], cascadingData = function (ref) { return {a: 'b', c: {_ref: `localhost.example.com/_components/${ref}`}}; diff --git a/test/api/_layouts/delete.js b/test/api/_layouts/delete.js new file mode 100644 index 00000000..b37d6e45 --- /dev/null +++ b/test/api/_layouts/delete.js @@ -0,0 +1,134 @@ +'use strict'; + +const _ = require('lodash'), + apiAccepts = require('../../fixtures/api-accepts'), + endpointName = _.startCase(__dirname.split('/').pop()), + filename = _.startCase(__filename.split('/').pop().split('.').shift()), + sinon = require('sinon'); + +describe(endpointName, function () { + describe(filename, function () { + let sandbox, + hostname = 'localhost.example.com', + acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)), + acceptsHtml = apiAccepts.acceptsHtml(_.camelCase(filename)), + data = { main: 'main' }; + + before(function () { + sandbox = sinon.sandbox.create(); + this.timeout(500); + return apiAccepts.beforeTesting(this, { + hostname, + data, + sandbox + }).then(function () { + sandbox.restore(); + }); + }); + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('/_components', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {}, 405, { allow:['get'], code: 405, message: 'Method DELETE not allowed' }); + acceptsHtml(path, {}, 405, '405 Method DELETE not allowed'); + }); + + describe('/_layouts/:name', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { + '/_layouts/valid': data + }}); + }); + + acceptsJson(path, {name: 'invalid'}, 404, { message: 'Not Found', code: 404 }); + acceptsJson(path, {name: 'valid'}, 200, data); + acceptsJson(path, {name: 'missing'}, 404, { message: 'Not Found', code: 404 }); + + acceptsHtml(path, {name: 'invalid'}, 404, '404 Not Found'); + acceptsHtml(path, {name: 'valid'}, 406, '406 text/html not acceptable'); + acceptsHtml(path, {name: 'missing'}, 406, '406 text/html not acceptable'); + }); + + describe('/_layouts/:name/schema', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid'}, 404, { message: 'Not Found', code: 404 }); + acceptsJson(path, {name: 'valid'}, 405, { allow:['get'], code: 405, message: 'Method DELETE not allowed' }); + acceptsJson(path, {name: 'missing'}, 405, { allow:['get'], code: 405, message: 'Method DELETE not allowed' }); + + acceptsHtml(path, {name: 'invalid'}, 404, '404 Not Found'); + acceptsHtml(path, {name: 'valid'}, 405, '405 Method DELETE not allowed'); + acceptsHtml(path, {name: 'missing'}, 405, '405 Method DELETE not allowed'); + }); + + describe('/_layouts/:name/instances', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid'}, 404, { message: 'Not Found', code: 404 }); + acceptsJson(path, {name: 'valid'}, 405, { allow:['get', 'post'], code: 405, message: 'Method DELETE not allowed' }); + acceptsJson(path, {name: 'missing'}, 405, { allow:['get', 'post'], code: 405, message: 'Method DELETE not allowed' }); + + acceptsHtml(path, {name: 'invalid'}, 404, '404 Not Found'); + acceptsHtml(path, {name: 'valid'}, 406, '406 text/html not acceptable'); + acceptsHtml(path, {name: 'missing'}, 406, '406 text/html not acceptable'); + }); + + describe('/_layouts/:name/instances/:id', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { + '/_layouts/valid/instances/valid': data + }}); + }); + + acceptsJson(path, {name: 'invalid', id: 'valid'}, 404, { message: 'Not Found', code: 404 }); + acceptsJson(path, {name: 'valid', id: 'valid'}, 200, data); + acceptsJson(path, {name: 'valid', id: 'missing'}, 404, { message: 'Not Found', code: 404 }); + + acceptsHtml(path, {name: 'invalid', id: 'valid'}, 404, '404 Not Found'); + acceptsHtml(path, {name: 'valid', id: 'valid'}, 406, '406 text/html not acceptable'); + acceptsHtml(path, {name: 'valid', id: 'missing'}, 406, '406 text/html not acceptable'); + }); + + describe('/_layouts/:name/instances/:id@:version', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { + '/_layouts/valid/instances/valid@valid': data + }}); + }); + + acceptsJson(path, {name: 'invalid', id: 'valid', version: 'valid'}, 404, { message: 'Not Found', code: 404 }); + acceptsJson(path, {name: 'valid', id: 'valid', version: 'valid'}, 200, data); + acceptsJson(path, {name: 'valid', id: 'missing', version: 'valid'}, 404, { message: 'Not Found', code: 404 }); + + acceptsHtml(path, {name: 'invalid', id: 'valid', version: 'valid'}, 404, '404 Not Found'); + acceptsHtml(path, {name: 'valid', id: 'valid', version: 'valid'}, 406, '406 text/html not acceptable'); + acceptsHtml(path, {name: 'valid', id: 'missing', version: 'valid'}, 406, '406 text/html not acceptable'); + }); + }); +}); diff --git a/test/api/_layouts/get.js b/test/api/_layouts/get.js new file mode 100644 index 00000000..8e67611e --- /dev/null +++ b/test/api/_layouts/get.js @@ -0,0 +1,162 @@ +'use strict'; + +const _ = require('lodash'), + apiAccepts = require('../../fixtures/api-accepts'), + endpointName = _.startCase(__dirname.split('/').pop()), + filename = _.startCase(__filename.split('/').pop().split('.').shift()), + sinon = require('sinon'); + +describe(endpointName, function () { + describe(filename, function () { + let sandbox, + hostname = 'localhost.example.com', + acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)), + acceptRedirect = apiAccepts.acceptRedirect(_.camelCase(filename)), + data = { main: 'main' }, + deepData = { d: 'e' }, + cascadingData = ref => { + return {a: 'b', c: {_ref: `localhost.example.com/_layouts/${ref}`}}; + }, + cascadingReturnData = ref => { + return {a: 'b', c: {_ref: `localhost.example.com/_layouts/${ref}`, d: 'e'}}; + }; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('/_layouts', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { + '/_layouts/valid': data.firstLevelComponent, + '/_pages/valid': data.page, + '/_pages/valid@valid': data.page + }}); + }); + + // only pages, and only unversioned + acceptsJson(path, {}, 200, '["layout1","layout2","layout3"]'); + }); + + describe('/_layouts/:name/instances', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { + '/_layouts/valid': data, + '/_layouts/valid/instances/valid': data, + '/_layouts/valid/instances/valid@valid': data + }}); + }); + + acceptsJson(path, {name: 'invalid'}, 404); + // no versioned or base instances in list + acceptsJson(path, {name: 'valid'}, 200, '["localhost.example.com/_layouts/valid/instances/valid"]'); + acceptsJson(path, {name: 'missing'}, 200, '[]'); + }); + + describe('/_layouts/:name@:version', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { + '/_layouts/valid@valid': data + }}); + }); + + acceptsJson(path, {name: 'invalid', version: 'missing'}, 404); + acceptsJson(path, {name: 'valid', version: 'missing'}, 404); + acceptsJson(path, {name: 'valid', version: 'valid'}, 200, data); + acceptsJson(path, {name: 'missing', version: 'missing'}, 404); + + // deny trailing slash + acceptsJson(path + '/', {name: 'valid', version: 'valid'}, 400, { message: 'Trailing slash on RESTful id in URL is not acceptable', code: 400 }); + }); + + describe('/_layouts/:name/schema', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid'}, 404); + acceptsJson(path, {name: 'valid'}, 200, {some:'schema', thatIs:'valid'}); + acceptsJson(path, {name: 'missing'}, 404); + }); + + describe('/_layouts/:name/instances/@published', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { + '/_layouts/valid': data, + '/_layouts/valid/instances/valid': data, + '/_layouts/valid/instances/valid@published': data + }}); + }); + + acceptsJson(path, {name: 'invalid'}, 404); + acceptsJson(path, {name: 'valid'}, 200, '["localhost.example.com/_layouts/valid/instances/valid@published"]'); + acceptsJson(path, {name: 'missing'}, 200, '[]'); + }); + + describe('/_layouts/:name/instances/:id', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { + '/_layouts/valid/instances/valid': data + }}); + }); + + acceptsJson(path, {name: 'invalid', id: 'valid'}, 404); + acceptsJson(path, {name: 'valid', id: 'valid'}, 200, data); + acceptsJson(path, {name: 'valid', id: 'missing'}, 404); + + // deny trailing slash + acceptsJson(path + '/', {name: 'valid', id: 'valid'}, 400, { message: 'Trailing slash on RESTful id in URL is not acceptable', code: 400 }); + }); + + describe('/_layouts/:name/instances/:id.json', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { + '/_layouts/valid/instances/valid': cascadingData('valid-deep/instances/valid-deep'), + '/_layouts/valid-deep/instances/valid-deep': deepData + }}); + }); + + acceptsJson(path, {name: 'invalid', id: 'valid'}, 404); + acceptsJson(path, {name: 'valid', id: 'valid'}, 200, cascadingReturnData('valid-deep/instances/valid-deep')); + acceptsJson(path, {name: 'valid', id: 'missing'}, 404); + }); + + describe('/_layouts/:name/instances/:id/meta', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, { name: 'valid', id: 'valid' }, 200, {}); + }); + + describe('/_layouts/:name/instances/:id@published/meta', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptRedirect(path, { name: 'valid', id: 'valid' }, 303, {}); + }); + }); +}); diff --git a/test/api/_layouts/patch.js b/test/api/_layouts/patch.js new file mode 100644 index 00000000..aa7fba22 --- /dev/null +++ b/test/api/_layouts/patch.js @@ -0,0 +1,33 @@ +'use strict'; + +const _ = require('lodash'), + apiAccepts = require('../../fixtures/api-accepts'), + endpointName = _.startCase(__dirname.split('/').pop()), + filename = _.startCase(__filename.split('/').pop().split('.').shift()), + sinon = require('sinon'); + +describe(endpointName, function () { + describe(filename, function () { + let sandbox, + hostname = 'localhost.example.com', + acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)); + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('/_layouts/:name/instances/:id/meta', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, { name: 'valid', id: 'valid' }, 200, '"[object Object]"'); + }); + }); +}); diff --git a/test/api/_layouts/post.js b/test/api/_layouts/post.js new file mode 100644 index 00000000..380106a6 --- /dev/null +++ b/test/api/_layouts/post.js @@ -0,0 +1,109 @@ +'use strict'; + +const _ = require('lodash'), + apiAccepts = require('../../fixtures/api-accepts'), + endpointName = _.startCase(__dirname.split('/').pop()), + filename = _.startCase(__filename.split('/').pop().split('.').shift()), + sinon = require('sinon'); + +describe(endpointName, function () { + describe(filename, function () { + let sandbox, + hostname = 'localhost.example.com', + acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)), + acceptsJsonBody = apiAccepts.acceptsJsonBody(_.camelCase(filename)), + acceptsHtml = apiAccepts.acceptsHtml(_.camelCase(filename)), + expectDataPlusRef = apiAccepts.expectDataPlusRef, + data = { name: 'Manny', species: 'cat' }; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('/_components', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {}, 405); + acceptsHtml(path, {}, 405); + }); + + describe('/_layouts/:name', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid'}, 404, { message: 'Not Found', code: 404 }); + acceptsJson(path, {name: 'valid'}, 405, { allow:['get', 'put', 'delete'], code: 405, message: 'Method POST not allowed' }); + acceptsJson(path, {name: 'missing'}, 405, { allow:['get', 'put', 'delete'], code: 405, message: 'Method POST not allowed' }); + + acceptsHtml(path, {name: 'invalid'}, 404, '404 Not Found'); + acceptsHtml(path, {name: 'valid'}, 406, '406 text/html not acceptable'); + acceptsHtml(path, {name: 'missing'}, 406, '406 text/html not acceptable'); + }); + + describe('/_layouts/:name/schema', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid'}, 404, { message: 'Not Found', code: 404 }); + acceptsJson(path, {name: 'valid'}, 405, { allow:['get'], code: 405, message: 'Method POST not allowed' }); + acceptsJson(path, {name: 'missing'}, 405, { allow:['get'], code: 405, message: 'Method POST not allowed' }); + + acceptsHtml(path, {name: 'invalid'}, 404, '404 Not Found'); + acceptsHtml(path, {name: 'valid'}, 405, '405 Method POST not allowed'); + acceptsHtml(path, {name: 'missing'}, 405, '405 Method POST not allowed'); + }); + + describe('/_layouts/:name/instances', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid'}, 404, { message: 'Not Found', code: 404 }); + acceptsJson(path, {name: 'valid'}, 200, expectDataPlusRef({})); + acceptsJson(path, {name: 'missing'}, 200, expectDataPlusRef({})); + + acceptsJsonBody(path, {name: 'invalid'}, {}, 404, { message: 'Not Found', code: 404 }); + acceptsJsonBody(path, {name: 'valid'}, data, 200, expectDataPlusRef(data)); + acceptsJsonBody(path, {name: 'missing'}, data, 200, expectDataPlusRef(data)); + + acceptsHtml(path, {name: 'invalid'}, 404, '404 Not Found'); + acceptsHtml(path, {name: 'valid'}, 406, '406 text/html not acceptable'); + acceptsHtml(path, {name: 'missing'}, 406, '406 text/html not acceptable'); + + // block with _ref at root of object + acceptsJsonBody(path, {name: 'valid'}, _.assign({_ref: 'whatever'}, data), 400, {message: 'Reference (_ref) at root of object is not acceptable', code: 400}); + }); + + describe('/_layouts/:name/instances/:id', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid', id: 'valid'}, 404, { message: 'Not Found', code: 404 }); + acceptsJson(path, {name: 'valid', id: 'valid'}, 405, { allow:['get', 'put', 'delete'], code: 405, message: 'Method POST not allowed' }); + acceptsJson(path, {name: 'valid', id: 'missing'}, 405, { allow:['get', 'put', 'delete'], code: 405, message: 'Method POST not allowed' }); + + acceptsHtml(path, {name: 'invalid', id: 'valid'}, 404, '404 Not Found'); + acceptsHtml(path, {name: 'valid', id: 'valid'}, 406, '406 text/html not acceptable'); + acceptsHtml(path, {name: 'valid', id: 'missing'}, 406, '406 text/html not acceptable'); + }); + }); +}); diff --git a/test/api/_layouts/put.js b/test/api/_layouts/put.js new file mode 100644 index 00000000..54e86445 --- /dev/null +++ b/test/api/_layouts/put.js @@ -0,0 +1,222 @@ +'use strict'; + +const _ = require('lodash'), + apiAccepts = require('../../fixtures/api-accepts'), + endpointName = _.startCase(__dirname.split('/').pop()), + filename = _.startCase(__filename.split('/').pop().split('.').shift()), + replaceVersion = require('../../../lib/services/references').replaceVersion, + sinon = require('sinon'); + +describe(endpointName, function () { + describe(filename, function () { + let sandbox, + hostname = 'localhost.example.com', + acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)), + acceptsJsonBody = apiAccepts.acceptsJsonBody(_.camelCase(filename)), + cascades = apiAccepts.cascades(_.camelCase(filename)), + data = { name: 'Manny', species: 'cat' }, + cascadingTarget = 'localhost.example.com/_layouts/validDeep', + addVersion = _.partial(replaceVersion, cascadingTarget), + cascadingData = function (version) { + return {a: 'b', c: {_ref: addVersion(version), d: 'e'}}; + }, + cascadingReturnData = function (version) { + return {a: 'b', c: {_ref: addVersion(version)}}; + }, + cascadingDeepData = {d: 'e'}; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('/_layouts', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {}, 405, { allow:['get'], code: 405, message: 'Method PUT not allowed' }); + acceptsJsonBody(path, {}, {}, 405, { allow:['get'], code: 405, message: 'Method PUT not allowed' }); + }); + + describe('/_layouts/:name', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid'}, 404, { code: 404, message: 'Not Found' }); + acceptsJson(path, {name: 'valid'}, 200, {}); + acceptsJson(path, {name: 'missing'}, 200, {}); + + acceptsJsonBody(path, {name: 'invalid'}, {}, 404, { code: 404, message: 'Not Found' }); + acceptsJsonBody(path, {name: 'valid'}, data, 200, data); + acceptsJsonBody(path, {name: 'missing'}, data, 200, data); + acceptsJsonBody(path, {name: 'valid'}, cascadingData(), 200, cascadingReturnData()); + + cascades(path, {name: 'valid'}, cascadingData(), cascadingTarget, cascadingDeepData); + + // block with _ref at root of object + acceptsJsonBody(path, {name: 'valid'}, _.assign({_ref: 'whatever'}, data), 400, {message: 'Reference (_ref) at root of object is not acceptable', code: 400}); + + // deny trailing slashes + acceptsJsonBody(path + '/', {name: 'valid'}, data, 400, { message: 'Trailing slash on RESTful id in URL is not acceptable', code: 400 }); + }); + + describe('/_layouts/:name/schema', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid'}, 404); + acceptsJson(path, {name: 'valid'}, 405, { allow:['get'], code: 405, message: 'Method PUT not allowed' }); + acceptsJson(path, {name: 'missing'}, 405, { allow:['get'], code: 405, message: 'Method PUT not allowed' }); + }); + + describe('/_layouts/:name@:version', function () { + const path = this.title, + version = 'def'; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid', version}, 404, { code: 404, message: 'Not Found' }); + acceptsJson(path, {name: 'valid', version}, 200, {}); + acceptsJson(path, {name: 'missing', version}, 200, {}); + + acceptsJsonBody(path, {name: 'invalid', version}, {}, 404, { code: 404, message: 'Not Found' }); + acceptsJsonBody(path, {name: 'valid', version}, data, 200, data); + acceptsJsonBody(path, {name: 'missing', version}, data, 200, data); + acceptsJsonBody(path, {name: 'valid', version}, cascadingData(), 200, cascadingReturnData()); + + cascades(path, {name: 'valid', version}, cascadingData(), cascadingTarget, cascadingDeepData); + + // block with _ref at root of object + acceptsJsonBody(path, {name: 'valid', version}, _.assign({_ref: 'whatever'}, data), 400, {message: 'Reference (_ref) at root of object is not acceptable', code: 400}); + + // deny trailing slashes + acceptsJsonBody(path + '/', {name: 'valid', version}, data, 400, { message: 'Trailing slash on RESTful id in URL is not acceptable', code: 400 }); + }); + + describe('/_layouts/:name/instances', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid'}, 404, { code: 404, message: 'Not Found' }); + acceptsJson(path, {name: 'valid'}, 405, { allow:['get', 'post'], code: 405, message: 'Method PUT not allowed' }); + acceptsJson(path, {name: 'missing'}, 405, { allow:['get', 'post'], code: 405, message: 'Method PUT not allowed' }); + + acceptsJsonBody(path, {name: 'invalid'}, {}, 404, { code: 404, message: 'Not Found' }); + acceptsJsonBody(path, {name: 'valid'}, data, 405, { allow:['get', 'post'], code: 405, message: 'Method PUT not allowed' }); + acceptsJsonBody(path, {name: 'missing'}, data, 405, { allow:['get', 'post'], code: 405, message: 'Method PUT not allowed' }); + }); + + describe('/_layouts/:name/instances/:id', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid', id: 'valid'}, 404, { code: 404, message: 'Not Found' }); + acceptsJson(path, {name: 'valid', id: 'valid'}, 200, {}); + acceptsJson(path, {name: 'valid', id: 'missing'}, 200, {}); + + acceptsJsonBody(path, {name: 'invalid', id: 'valid'}, {}, 404, { code: 404, message: 'Not Found' }); + acceptsJsonBody(path, {name: 'valid', id: 'valid'}, data, 200, data); + acceptsJsonBody(path, {name: 'missing', id: 'missing'}, data, 200, data); + acceptsJsonBody(path, {name: 'valid'}, cascadingData(), 200, cascadingReturnData()); + + cascades(path, {name: 'valid', id: 'valid'}, cascadingData(), cascadingTarget, cascadingDeepData); + + // block with _ref at root of object + acceptsJsonBody(path, {name: 'valid', id: 'valid'}, _.assign({_ref: 'whatever'}, data), 400, {message: 'Reference (_ref) at root of object is not acceptable', code: 400}); + + // deny trailing slashes + acceptsJsonBody(path + '/', {name: 'valid', id: 'valid'}, data, 400, { message: 'Trailing slash on RESTful id in URL is not acceptable', code: 400 }); + }); + + describe('/_layouts/:name/instances/:id.json', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, {name: 'invalid', id: 'valid'}, 404, { code: 404, message: 'Not Found' }); + acceptsJson(path, {name: 'valid', id: 'valid'}, 200, {}); + acceptsJson(path, {name: 'valid', id: 'missing'}, 200, {}); + + acceptsJsonBody(path, {name: 'invalid', id: 'valid'}, {}, 404, { code: 404, message: 'Not Found' }); + acceptsJsonBody(path, {name: 'valid', id: 'valid'}, data, 200, data); + + acceptsJsonBody(path, {name: 'missing', id: 'missing'}, data, 404, { code: 404, message: 'Not Found' }); + + acceptsJsonBody(path, {name: 'valid'}, cascadingData(), 200, cascadingData()); + + cascades(path, {name: 'valid', id: 'valid'}, cascadingData(), cascadingTarget, cascadingDeepData); + + // block with _ref at root of object + acceptsJsonBody(path, {name: 'valid', id: 'valid'}, _.assign({_ref: 'whatever'}, data), 400, {message: 'Reference (_ref) at root of object is not acceptable', code: 400}); + }); + + describe('/_layouts/:name/instances/:id@:version', function () { + let path = this.title, + version = 'scheduled'; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { + '/_layouts/valid/instances/valid': data + }}); + }); + + acceptsJson(path, {name: 'invalid', version, id: 'valid'}, 404, { code: 404, message: 'Not Found' }); + acceptsJson(path, {name: 'valid', version, id: 'valid'}, 200, {}); + acceptsJson(path, {name: 'valid', version, id: 'missing'}, 200, {}); + + acceptsJsonBody(path, {name: 'invalid', version, id: 'valid'}, {}, 404, { code: 404, message: 'Not Found' }); + acceptsJsonBody(path, {name: 'valid', version, id: 'valid'}, data, 200, data); + acceptsJsonBody(path, {name: 'missing', version, id: 'missing'}, data, 200, data); + acceptsJsonBody(path, {name: 'valid', version, id: 'valid'}, cascadingData(), 200, cascadingReturnData()); + + cascades(path, {name: 'valid', version, id: 'valid'}, cascadingData(), cascadingTarget, cascadingDeepData); + + // published version + version = 'published'; + acceptsJsonBody(path, {name: 'valid', version, id: 'valid'}, data, 200, data); + acceptsJsonBody(path, {name: 'valid', version, id: 'valid'}, cascadingData(version), 200, cascadingReturnData(version)); + cascades(path, {name: 'valid', version, id: 'valid'}, cascadingData(version), addVersion(version), cascadingDeepData); + + // published blank data will publish @published + acceptsJsonBody(path, {name: 'valid', version, id: 'valid'}, data, 200, data); + + // block with _ref at root of object + acceptsJsonBody(path, {name: 'valid', version, id: 'valid'}, _.assign({_ref: 'whatever'}, data), 400, {message: 'Reference (_ref) at root of object is not acceptable', code: 400}); + + // deny trailing slashes + acceptsJsonBody(path + '/', {name: 'valid', version, id: 'valid'}, data, 400, { message: 'Trailing slash on RESTful id in URL is not acceptable', code: 400 }); + }); + + describe('/_layouts/:name/instances/:id/meta', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJsonBody(path, { name: 'valid', id: 'valid' }, {name: 'foo'}, 200, {name: 'foo'}); + }); + }); +}); diff --git a/test/api/_pages/get.js b/test/api/_pages/get.js index a5eead5e..5b49e195 100644 --- a/test/api/_pages/get.js +++ b/test/api/_pages/get.js @@ -11,6 +11,7 @@ describe(endpointName, function () { let sandbox, hostname = 'localhost.example.com', acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)), + acceptRedirect = apiAccepts.acceptRedirect(_.camelCase(filename)), pageData = { layout: 'localhost.example.com/_components/layout', center: ['localhost.example.com/_components/valid'] }, layoutData = { center: 'center', deep: [{_ref: 'localhost.example.com/_components/validDeep'}] }, deepData = { _ref: 'localhost.example.com/_components/validDeep' }, @@ -116,5 +117,25 @@ describe(endpointName, function () { // blocks trailing slash acceptsJson(path + '/', {name: 'valid', version: 'valid'}, 400, { message: 'Trailing slash on RESTful id in URL is not acceptable', code: 400 }); }); + + describe('/_pages/:name/meta', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, { name: 'valid' }, 200, {}); + }); + + describe('/_pages/:name@published/meta', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptRedirect(path, { name: 'valid' }, 303, {}); + }); }); }); diff --git a/test/api/_pages/patch.js b/test/api/_pages/patch.js new file mode 100644 index 00000000..34471be4 --- /dev/null +++ b/test/api/_pages/patch.js @@ -0,0 +1,33 @@ +'use strict'; + +const _ = require('lodash'), + apiAccepts = require('../../fixtures/api-accepts'), + endpointName = _.startCase(__dirname.split('/').pop()), + filename = _.startCase(__filename.split('/').pop().split('.').shift()), + sinon = require('sinon'); + +describe(endpointName, function () { + describe(filename, function () { + let sandbox, + hostname = 'localhost.example.com', + acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)); + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('/_pages/:name/meta', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJson(path, { name: 'valid', id: 'valid' }, 200, '"[object Object]"'); + }); + }); +}); diff --git a/test/api/_pages/post.js b/test/api/_pages/post.js index 9738bfe1..7d879a86 100644 --- a/test/api/_pages/post.js +++ b/test/api/_pages/post.js @@ -15,7 +15,7 @@ describe(endpointName, function () { acceptsJsonBody = apiAccepts.acceptsJsonBody(_.camelCase(filename)), acceptsHtml = apiAccepts.acceptsHtml(_.camelCase(filename)), pageData = { - layout: 'localhost.example.com/_components/layout', + layout: 'localhost.example.com/_layouts/layout', center: 'localhost.example.com/_components/valid', side: ['localhost.example.com/_components/valid@valid'] }, @@ -40,7 +40,7 @@ describe(endpointName, function () { beforeEach(function () { return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { - '/_components/layout': data.layout, + '/_layouts/layout': data.layout, '/_components/valid': data.firstLevelComponent, '/_components/valid@valid': data.firstLevelComponent, '/_pages/valid': data.page @@ -53,7 +53,6 @@ describe(endpointName, function () { const body = result.body; expect(body.center).to.match(/^localhost.example.com\/_components\/valid\/instances\/.+/); - // expect(body.side[0]).to.match(/^localhost.example.com\/_components\/valid\/instances\/.+/); expect(body.layout).to.equal(pageData.layout); expect(body._ref).to.match(/^localhost.example.com\/_pages\/.+/); }); diff --git a/test/api/_pages/put.js b/test/api/_pages/put.js index 0f0cd406..a087e0f5 100644 --- a/test/api/_pages/put.js +++ b/test/api/_pages/put.js @@ -17,13 +17,13 @@ describe(endpointName, function () { cascades = apiAccepts.cascades(_.camelCase(filename)), pageData = { url: 'http://localhost.example.com', - layout: 'localhost.example.com/_components/layout', + layout: 'localhost.example.com/_layouts/layout', center: 'localhost.example.com/_components/valid', side: ['localhost.example.com/_components/valid@valid'] }, cascadingPageData = { url: 'http://localhost.example.com', - layout: 'localhost.example.com/_components/layoutCascading', + layout: 'localhost.example.com/_layouts/layoutCascading', center: 'localhost.example.com/_components/validCascading', side: ['localhost.example.com/_components/validCascading@valid'] }, @@ -33,27 +33,21 @@ describe(endpointName, function () { cascadingTarget = 'localhost.example.com/_components/validDeep', versionedPageData = function (version) { return { - urlHistory: [ - 'http://localhost.example.com' - ], url: 'http://localhost.example.com', - layout: 'localhost.example.com/_components/layout@' + version, - center: 'localhost.example.com/_components/valid@' + version, - side: ['localhost.example.com/_components/valid@' + version] + layout: `localhost.example.com/_layouts/layout@${version}`, + center: `localhost.example.com/_components/valid@${version}`, + side: [`localhost.example.com/_components/valid@${version}`] }; }, versionedDeepData = function (version) { - return { deep: {_ref: 'localhost.example.com/_components/validDeep@' + version} }; + return { deep: {_ref: `localhost.example.com/_components/validDeep@${version}`} }; }, cascadingReturnData = function (version) { return { - urlHistory: [ - 'http://localhost.example.com' - ], url: 'http://localhost.example.com', - layout: 'localhost.example.com/_components/layoutCascading@' + version, - center: 'localhost.example.com/_components/validCascading@' + version, - side: ['localhost.example.com/_components/validCascading@' + version] + layout: `localhost.example.com/_layouts/layoutCascading@${version}`, + center: `localhost.example.com/_components/validCascading@${version}`, + side: [`localhost.example.com/_components/validCascading@${version}`] }; }, data = { @@ -90,8 +84,9 @@ describe(endpointName, function () { return apiAccepts.beforeEachTest({ sandbox, hostname }); }); - acceptsJson(path, {name: 'valid'}, 200, {}); - acceptsJson(path, {name: 'missing'}, 200, {}); + // Can't send an empty page object + acceptsJson(path, {name: 'valid'}, 500, {message: 'Page must contain a `layout` property whose value is a `_layouts` instance', code: 500}); + acceptsJson(path, {name: 'missing'}, 500, {message: 'Page must contain a `layout` property whose value is a `_layouts` instance', code: 500}); acceptsJsonBody(path, {name: 'valid'}, pageData, 200, pageData); acceptsJsonBody(path, {name: 'missing'}, pageData, 200, pageData); @@ -109,9 +104,9 @@ describe(endpointName, function () { beforeEach(function () { return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { - '/_components/layout': data.layout, - '/_components/layout@valid': data.layout, - '/_components/layoutCascading': data.firstLevelComponent, + '/_layouts/layout': data.layout, + '/_layouts/layout@valid': data.layout, + '/_layouts/layoutCascading': data.firstLevelComponent, '/_components/valid': data.firstLevelComponent, '/_components/valid@valid': data.firstLevelComponent, '/_components/validCascading': data.firstLevelComponent, @@ -145,7 +140,17 @@ describe(endpointName, function () { // block with _ref at root of object acceptsJsonBody(path, {name: 'valid', version}, _.assign({_ref: 'whatever'}, pageData), 400, {message: 'Reference (_ref) at root of object is not acceptable', code: 400}); - acceptsJsonBody(path + '/', {name: 'valid', version}, pageData, 400, { message: 'Trailing slash on RESTful id in URL is not acceptable', code: 400 }); + acceptsJsonBody(`${path}/`, {name: 'valid', version}, pageData, 400, { message: 'Trailing slash on RESTful id in URL is not acceptable', code: 400 }); + }); + + describe('/_pages/:name/meta', function () { + const path = this.title; + + beforeEach(function () { + return apiAccepts.beforeEachTest({ sandbox, hostname }); + }); + + acceptsJsonBody(path, { name: 'valid' }, {name: 'foo'}, 200, {name: 'foo'}); }); }); }); diff --git a/test/api/_schedule/delete.js b/test/api/_schedule/delete.js deleted file mode 100644 index 94c592de..00000000 --- a/test/api/_schedule/delete.js +++ /dev/null @@ -1,58 +0,0 @@ -'use strict'; - -const _ = require('lodash'), - apiAccepts = require('../../fixtures/api-accepts'), - endpointName = _.startCase(__dirname.split('/').pop()), - filename = _.startCase(__filename.split('/').pop().split('.').shift()), - sinon = require('sinon'); - -describe(endpointName, function () { - describe(filename, function () { - let sandbox, - hostname = 'localhost.example.com', - acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)), - acceptsJsonBody = apiAccepts.acceptsJsonBody(_.camelCase(filename)), - acceptsHtml = apiAccepts.acceptsHtml(_.camelCase(filename)), - scheduleData = { at: new Date('2015-01-01').getTime(), publish: 'http://localhost.example.com/_pages/valid' }; - - beforeEach(function () { - sandbox = sinon.sandbox.create(); - sandbox.useFakeTimers(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - describe('/_schedule', function () { - const path = this.title; - - beforeEach(function () { - return apiAccepts.beforeEachTest({ sandbox, hostname }); - }); - - acceptsJson(path, {}, 405, { allow:['get', 'post'], code: 405, message: 'Method DELETE not allowed' }); - acceptsJsonBody(path, {}, {}, 405, { allow:['get', 'post'], code: 405, message: 'Method DELETE not allowed' }); - acceptsHtml(path, {}, 405, '405 Method DELETE not allowed'); - }); - - describe('/_schedule/:name', function () { - const path = this.title; - - beforeEach(function () { - return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { - '/_schedule/valid': scheduleData - }}); - }); - - acceptsJson(path, {name: 'valid'}, 200, scheduleData); - acceptsJson(path, {name: 'missing'}, 404, { message: 'Not Found', code: 404 }); - - acceptsJsonBody(path, {name: 'valid'}, scheduleData, 200, scheduleData); - acceptsJsonBody(path, {name: 'missing'}, scheduleData, 404, { message: 'Not Found', code: 404 }); - - acceptsHtml(path, {name: 'valid'}, 406, '406 text/html not acceptable'); - acceptsHtml(path, {name: 'missing'}, 406, '406 text/html not acceptable'); - }); - }); -}); diff --git a/test/api/_schedule/get.js b/test/api/_schedule/get.js deleted file mode 100644 index 631b285f..00000000 --- a/test/api/_schedule/get.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -const _ = require('lodash'), - apiAccepts = require('../../fixtures/api-accepts'), - endpointName = _.startCase(__dirname.split('/').pop()), - filename = _.startCase(__filename.split('/').pop().split('.').shift()), - sinon = require('sinon'); - -describe(endpointName, function () { - describe(filename, function () { - let sandbox, - hostname = 'localhost.example.com', - acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)), - acceptsHtml = apiAccepts.acceptsHtml(_.camelCase(filename)), - scheduleData = {}, - pageData = {}; - - beforeEach(function () { - sandbox = sinon.sandbox.create(); - sandbox.useFakeTimers(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - describe('/_schedule', function () { - const path = this.title; - - beforeEach(function () { - return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { - '/_schedule/valid': scheduleData - }}); - }); - - acceptsJson(path, {}, 200, '[{"_ref":"localhost.example.com/_schedule/valid"}]'); - acceptsHtml(path, {}, 406); - }); - - describe('/_schedule/:name', function () { - const path = this.title; - - beforeEach(function () { - return apiAccepts.beforeEachTest({ sandbox, hostname, pathsAndData: { - '/_schedule/valid': scheduleData - }}); - }); - - acceptsJson(path, {name: 'valid'}, 200, pageData); - acceptsJson(path, {name: 'missing'}, 404, { message: 'Not Found', code: 404 }); - acceptsHtml(path, {name: 'valid'}, 406, '406 text/html not acceptable'); - acceptsHtml(path, {name: 'missing'}, 406, '406 text/html not acceptable'); - }); - }); -}); diff --git a/test/api/_schedule/post.js b/test/api/_schedule/post.js deleted file mode 100644 index e7d91e23..00000000 --- a/test/api/_schedule/post.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -const _ = require('lodash'), - apiAccepts = require('../../fixtures/api-accepts'), - endpointName = _.startCase(__dirname.split('/').pop()), - filename = _.startCase(__filename.split('/').pop().split('.').shift()), - sinon = require('sinon'); - -describe(endpointName, function () { - describe(filename, function () { - let sandbox, - hostname = 'localhost.example.com', - acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)), - acceptsJsonBody = apiAccepts.acceptsJsonBody(_.camelCase(filename)), - acceptsHtml = apiAccepts.acceptsHtml(_.camelCase(filename)), - time = new Date('2015-01-01').getTime(), - pageData = {}; - - beforeEach(function () { - sandbox = sinon.sandbox.create(); - sandbox.useFakeTimers(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - describe('/_schedule', function () { - const path = this.title; - - beforeEach(function () { - return apiAccepts.beforeEachTest({ sandbox, hostname }); - }); - - acceptsJson(path, {}, 400, { message: 'Missing "at" property as number.', code: 400 }); - acceptsHtml(path, {}, 406, '406 text/html not acceptable'); - - acceptsJsonBody(path, {}, {}, 400, { message: 'Missing "at" property as number.', code: 400 }); - acceptsJsonBody(path, {}, {at: time}, 400, { message: 'Missing "publish" property as valid url.', code: 400 }); - acceptsJsonBody(path, {}, {at: time, publish: 'http://abc'}, 201, { _ref: 'localhost.example.com/_schedule/YWJj', at: time, publish: 'http://abc' }); - }); - - describe('/_schedule/:name', function () { - const path = this.title; - - beforeEach(function () { - return apiAccepts.beforeEachTest({ sandbox, hostname }); - }); - - acceptsJson(path, {name: 'valid'}, 405, { allow:['get', 'delete'], code: 405, message: 'Method POST not allowed' }); - acceptsJsonBody(path, {name: 'valid'}, pageData, 405, { allow:['get', 'delete'], code: 405, message: 'Method POST not allowed' }); - acceptsHtml(path, {name: 'valid'}, 405, '405 Method POST not allowed'); - }); - }); -}); diff --git a/test/api/_schedule/put.js b/test/api/_schedule/put.js deleted file mode 100644 index 9e454751..00000000 --- a/test/api/_schedule/put.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -const _ = require('lodash'), - apiAccepts = require('../../fixtures/api-accepts'), - endpointName = _.startCase(__dirname.split('/').pop()), - filename = _.startCase(__filename.split('/').pop().split('.').shift()), - sinon = require('sinon'); - -describe(endpointName, function () { - describe(filename, function () { - let sandbox, - hostname = 'localhost.example.com', - acceptsJson = apiAccepts.acceptsJson(_.camelCase(filename)), - acceptsJsonBody = apiAccepts.acceptsJsonBody(_.camelCase(filename)), - acceptsHtml = apiAccepts.acceptsHtml(_.camelCase(filename)); - - beforeEach(function () { - sandbox = sinon.sandbox.create(); - sandbox.useFakeTimers(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - describe('/_schedule', function () { - const path = this.title; - - beforeEach(function () { - return apiAccepts.beforeEachTest({ sandbox, hostname }); - }); - - acceptsJson(path, {}, 405, { allow:['get', 'post'], code: 405, message: 'Method PUT not allowed' }); - acceptsJsonBody(path, {}, {}, 405, { allow:['get', 'post'], code: 405, message: 'Method PUT not allowed' }); - acceptsHtml(path, {}, 405, '405 Method PUT not allowed'); - }); - - describe('/_schedule/:name', function () { - const path = this.title; - - beforeEach(function () { - return apiAccepts.beforeEachTest({ sandbox, hostname }); - }); - - acceptsJson(path, {}, 405, { allow:['get', 'delete'], code: 405, message: 'Method PUT not allowed' }); - acceptsJsonBody(path, {}, {}, 405, { allow:['get', 'delete'], code: 405, message: 'Method PUT not allowed' }); - acceptsHtml(path, {}, 405, '405 Method PUT not allowed'); - }); - }); -}); diff --git a/test/api/render/index.test.js b/test/api/render/index.test.js index 4bb4a99b..79d294a9 100644 --- a/test/api/render/index.test.js +++ b/test/api/render/index.test.js @@ -3,7 +3,8 @@ const sinon = require('sinon'), render = require('../../../lib/render'), componentsRoutes = require('../../../lib/routes/_components'), - pagesRoutes = require('../../../lib/routes/_pages'); + pagesRoutes = require('../../../lib/routes/_pages'), + layoutRoutes = require('../../../lib/routes/_layouts'); describe('Custom Rendering', function () { let sandbox; @@ -60,4 +61,25 @@ describe('Custom Rendering', function () { sinon.assert.calledOnce(render.renderPage); }); }); + + describe('Layout Render', function () { + it('calls the render function if a renderer is found to match the extension', function () { + const fn = layoutRoutes.route.getExtension; + + render.rendererExists.returns(true); + sandbox.stub(layoutRoutes.route, 'render'); + fn({ params: {ext: 'html' }}); + sinon.assert.calledOnce(layoutRoutes.route.render); + }); + + it('calls the `renderComponent` function', function () { + const fn = layoutRoutes.route.render, + typeSpy = sinon.spy(), + sendSpy = sinon.spy(); + + render.renderComponent.returns(Promise.resolve({type: 'text/html', output: 'some html' })); + fn({}, { type: typeSpy, send: sendSpy }); + sinon.assert.calledOnce(render.renderComponent); + }); + }); }); diff --git a/test/fixtures/api-accepts.js b/test/fixtures/api-accepts.js index f5c4d16b..7db453a5 100644 --- a/test/fixtures/api-accepts.js +++ b/test/fixtures/api-accepts.js @@ -6,10 +6,12 @@ const _ = require('lodash'), files = require('../../lib/files'), routes = require('../../lib/routes'), db = require('../../lib/services/db'), + storage = require('./mocks/storage')(), bluebird = require('bluebird'), render = require('../../lib/render'), schema = require('../../lib/schema'), siteService = require('../../lib/services/sites'), + meta = require('../../lib/services/metadata'), expect = require('chai').expect, filter = require('through2-filter'), uid = require('../../lib/uid'), @@ -22,7 +24,7 @@ var app, host; * @returns {string} */ function getRealPath(replacements, path) { - return _.reduce(replacements, function (str, value, key) { return str.replace(':' + key, value); }, path); + return _.reduce(replacements, (str, value, key) => str.replace(`:${key}`, value), path); } /** @@ -52,8 +54,11 @@ function createTest(options) { .set('Host', host) .set('Authorization', 'token testKey'); - promise = promise.expect('Content-Type', options.contentType) - .expect(options.status); + if (options.contentType) { + promise = promise.expect('Content-Type', options.contentType); + } + + promise = promise.expect(options.status); if (options.data !== undefined) { promise = promise.expect(options.data); @@ -153,6 +158,25 @@ function acceptsJson(method) { }; } +/** + * Create a generic test that accepts JSON + * @param {String} method + * @returns {Function} + */ +function acceptRedirect(method) { + return function (path, replacements, status, data) { + createTest({ + description: JSON.stringify(replacements) + ' receives a redirect', + path, + method, + replacements, + data, + status, + accept: '*/*' + }); + }; +} + /** * Create a generic test that accepts JSON with a BODY * @param {string} method @@ -208,7 +232,7 @@ function updatesOther(method) { .set('Host', host) .set('Authorization', 'token testKey') .then(function () { - return db.get(host + realOtherPath).then(JSON.parse).then(function (result) { + return storage.getFromInMem(host + realOtherPath).then(function (result) { expect(result).to.deep.equal(data); }); }); @@ -226,7 +250,7 @@ function getVersions(ref) { deferred = bluebird.defer(), prefix = ref.split('@')[0]; - db.list({prefix, values: false, transforms: [filter({wantStrings: true}, function (str) { + storage.list({prefix, values: false, transforms: [filter({wantStrings: true}, function (str) { return str.indexOf('@') !== -1; })]}) .on('data', function (data) { @@ -291,7 +315,7 @@ function cascades(method) { .expect(200) .then(function () { // expect cascading data to now exist - return db.get(cascadingTarget).then(JSON.parse).then(function (result) { + return storage.getFromInMem(cascadingTarget).then(function (result) { expect(result).to.deep.equal(cascadingData); }); }); @@ -330,16 +354,30 @@ function stubSiteConfig(sandbox) { assetPath: '/' } }); + + sandbox.stub(siteService, 'getSiteFromPrefix').returns({ + host, + path: '/', + slug: 'example', + assetDir: 'public', + assetPath: '/' + }); } function stubFiles(sandbox) { sandbox.stub(files, 'getComponentPath'); + sandbox.stub(files, 'getLayoutPath'); files.getComponentPath.withArgs('valid').returns('validThing'); files.getComponentPath.withArgs('missing').returns('missingThing'); files.getComponentPath.withArgs('invalid').returns(null); + files.getLayoutPath.withArgs('valid').returns('validThing'); + files.getLayoutPath.withArgs('missing').returns('missingThing'); + files.getLayoutPath.withArgs('invalid').returns(null); + sandbox.stub(files, 'getComponents').returns(['clay-c5', 'clay-c3', 'clay-c4']); + sandbox.stub(files, 'getLayouts').returns(['layout1', 'layout2', 'layout3']); sandbox.stub(files, 'fileExists'); files.fileExists.withArgs('public').returns(true); @@ -389,6 +427,20 @@ function stubUid(sandbox) { return sandbox; } +function stubMeta(sandbox) { + sandbox.stub(meta, 'createPage').returns(Promise.resolve()); + sandbox.stub(meta, 'publishPage').returns(Promise.resolve()); + sandbox.stub(meta, 'unpublishPage').returns(Promise.resolve()); + return sandbox; +} + +function stubLoggers(sandbox) { + const fakeLog = sandbox.stub(); + + require('../../lib/services/pages').setLog(fakeLog); + require('../../lib/responses').setLog(fakeLog); +} + /** * Before starting testing at all, prepare certain things to make sure our performance testing is accurate. */ @@ -402,6 +454,7 @@ function beforeTesting(suite, options) { process.env.CLAY_ACCESS_KEY = 'testKey'; stubSiteConfig(options.sandbox); stubFiles(options.sandbox); + stubMeta(options.sandbox); stubSchema(options.sandbox); stubRenderExists(options.sandbox); stubRenderComponent(options.sandbox); @@ -413,7 +466,7 @@ function beforeTesting(suite, options) { sites: null }); - return db.clear().then(function () { + return storage.clearMem().then(function () { return bluebird.all([ request(app).put('/_components/valid', JSON.stringify(options.data)), request(app).get('/_components/valid'), @@ -441,9 +494,11 @@ function beforeEachTest(options) { stubSiteConfig(options.sandbox); stubFiles(options.sandbox); stubSchema(options.sandbox); + stubMeta(options.sandbox); stubRenderExists(options.sandbox); stubRenderComponent(options.sandbox); stubRenderPage(options.sandbox); + stubLoggers(options.sandbox); stubUid(options.sandbox); routes.addHost({ router: app, @@ -451,8 +506,16 @@ function beforeEachTest(options) { providers: ['apikey'] }); + db.get.callsFake(storage.getFromInMem); + db.put.callsFake(storage.writeToInMem); + db.batch.callsFake(storage.batchToInMem); + db.del.callsFake(storage.delFromInMem); + db.getLatestData.callsFake(storage.getLatestFromInMem); + db.putMeta.callsFake(storage.putMetaInMem); + db.patchMeta.callsFake(storage.patchMetaInMem); + db.getMeta.callsFake(storage.getMetaInMem); - return db.clear().then(function () { + return storage.clearMem().then(function () { if (options.pathsAndData) { return bluebird.all(_.map(options.pathsAndData, function (data, path) { let ignoreHost = path.indexOf(ignoreString) > -1; @@ -465,7 +528,7 @@ function beforeEachTest(options) { data = JSON.stringify(data); } - return db.put(`${ignoreHost ? '' : host}${path}`, data); + return storage.writeToInMem(`${ignoreHost ? '' : host}${path}`, data); })); } }); @@ -492,6 +555,7 @@ module.exports.setApp = setApp; module.exports.setHost = setHost; module.exports.acceptsHtml = acceptsHtml; module.exports.acceptsHtmlBody = acceptsHtmlBody; +module.exports.acceptRedirect = acceptRedirect; module.exports.acceptsJson = acceptsJson; module.exports.acceptsJsonBody = acceptsJsonBody; module.exports.acceptsText = acceptsText; diff --git a/test/fixtures/config/bad.yml b/test/fixtures/config/bad.yml new file mode 100644 index 00000000..a59fc551 --- /dev/null +++ b/test/fixtures/config/bad.yml @@ -0,0 +1,2 @@ +_components: + foo: \ No newline at end of file diff --git a/test/fixtures/mocks/storage.js b/test/fixtures/mocks/storage.js new file mode 100644 index 00000000..11da54ff --- /dev/null +++ b/test/fixtures/mocks/storage.js @@ -0,0 +1,164 @@ +'use strict'; + +const sinon = require('sinon'), + db = require('../../../lib/services/db'), + { replaceVersion } = require('clayutils'); + +class Storage { + constructor() { + this.inMem = require('levelup')('whatever', { db: require('memdown') }); + this.setup = sinon.stub().returns(Promise.resolve()); + this.get = sinon.stub(); + this.put = sinon.stub(); + this.del = sinon.stub(); + this.batch = sinon.stub(); + this.getMeta = sinon.stub(); + this.putMeta = sinon.stub(); + this.patchMeta = sinon.stub(); + this.getLists = sinon.stub(); + this.list = db.list; + this.clearMem = this.clear; + this.pipeToPromise = db.pipeToPromise; + this.createReadStream = (ops) => this.inMem.createReadStream(ops); + this.getLatestData = sinon.stub(); + + db.registerStorage(this); + } + + defer() { + return db.defer(); + } + + /** + * Save to inMemDb + * + * @param {String} key + * @param {String} value + * @return {Promise} + */ + writeToInMem(key, value) { + const deferred = this.defer(); + + this.inMem.put(key, value, deferred.apply); + return deferred.promise; + } + + getLatestFromInMem(key) { + const deferred = this.defer(); + + this.inMem.get(replaceVersion(key), deferred.apply); + return deferred.promise.then(resp => { + var returnVal; + + try { + returnVal = JSON.parse(resp); + } catch (e) { + returnVal = resp; + } + + return returnVal; + }); // Parse because storage modules are expected to + } + + + patchMetaInMem(key, value) { + const deferred = this.defer(); + + this.inMem.put(`${key}/meta`, value, deferred.apply); + return deferred.promise + .then(() => value); + } + + /** + * Get from the inMemDb + * @param {String} key + * @return {Promise} + */ + getFromInMem(key) { + const deferred = this.defer(); + + this.inMem.get(key, deferred.apply); + return deferred.promise.then(resp => { + var returnVal; + + try { + returnVal = JSON.parse(resp); + } catch (e) { + returnVal = resp; + } + + return returnVal; + }); // Parse because storage modules are expected to + } + + /** + * Delete to inMemDb + * @param {String} key + * @return {Promise} + */ + delFromInMem(key) { + const deferred = this.defer(); + + this.inMem.del(key, deferred.apply); + return deferred.promise; + } + + /** + * Process a batch + * @param {Array} ops + * @param {Object} options + * @return {Promise} + */ + batchToInMem(ops, options) { + const deferred = this.defer(); + + this.inMem.batch(ops, options || {}, deferred.apply); + + return deferred.promise; + } + + putMetaInMem(key, value) { + const deferred = this.defer(); + + this.inMem.put(`${key}/meta`, value, deferred.apply); + return deferred.promise + .then(() => value); + } + + getMetaInMem(key) { + const deferred = this.defer(); + + this.inMem.get(`${key}/meta`, deferred.apply); + return deferred.promise + .catch(() => ({})); + } + + /** + * Clear the Db + * @return {Promise} + */ + clear() { + const errors = [], + ops = [], + deferred = this.defer(); + + this.inMem.createReadStream({ + keys:true, + fillCache: false, + limit: -1 + }) + .on('data', data => ops.push({ type: 'del', key: data.key})) + .on('error', error => errors.push(error)) + .on('end', () => { + if (errors.length) { + deferred.apply(_.head(errors)); + } else { + this.inMem.batch(ops, deferred.apply); + } + }); + + return deferred.promise; + } +} + +module.exports = () => new Storage(); diff --git a/test/index.js b/test/index.js index 99a7f61d..b1a2ebb7 100644 --- a/test/index.js +++ b/test/index.js @@ -1,4 +1,5 @@ 'use strict'; + const glob = require('glob'), _ = require('lodash'), chai = require('chai'), @@ -6,16 +7,27 @@ const glob = require('glob'), tests = glob.sync([__dirname, '..', 'lib', '**', '*.test.js'].join(path.sep)), apiTests = glob.sync([__dirname, 'api', '**', '*.js'].join(path.sep)); -// defaults for chai +// defaults for chai1 chai.config.showDiff = true; chai.config.truncateThreshold = 0; // make sure the index file can be loaded at least require('..'); +// The DB service gets a little borked because of how we have +// to use it right now. What we need to do is write an in-memory +// store purely for testing amphora. This will come later, but +// right now the db tests need to run first +require('../lib/services/db.test'); -_.each(apiTests, test => require(test)); +_.each(apiTests, test => { + // if (_.includes(test, 'api/_pages/put')) require(test); + require(test); +}); -_.each(tests, test => require(test)); +_.each(tests, test => { + // if (_.includes(test, 'services/publish.test')) require(test); + require(test); +}); after(function () { require('./fixtures/enforce-performance')(this); diff --git a/v7-changes.md b/v7-changes.md new file mode 100644 index 00000000..b1570074 --- /dev/null +++ b/v7-changes.md @@ -0,0 +1,14 @@ +# Amphora Changes +- Old plugin system no longer works, only use the Bus +- The Bus env var name changed +- The old router does not work + + +# Amphora Search +- `/pagelist` endpoint is deprecated, send a `PATCH` to `/meta` with the properties needed for the page list +- Removed first pass at `handlers` API, now use the stream API +- Stream API only accepts subscriptions to Amphora Bus Events +- Page list entirely derived from page/layout `meta` + +# Amphora HTML +- Layouts and components are now namespaced in HBS instance \ No newline at end of file