diff --git a/CHANGELOG.md b/CHANGELOG.md old mode 100755 new mode 100644 index dae8ca4..620190e --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,60 @@ -# 4.3.1 / YYYY-MM-DD +# 5.0.0 / 2018-05-03 + +## BREAKING CHANGES + +- require('@openveo/api').util.shallowValidateObject now throws an error when trying to validate an Object as an array<string>, array<number> or array<object> +- Controller / Model / Provider / Database system has been revised into a Controller / Provider / Storage system with the following important consequences: + - Models have been entirely removed as the notion of Model was confusing. Consequently require('@openveo/api').models does not exist anymore. You should now directly use Provider and EntityProvider instead of Model and EntityModel. If you were using ContentModel, content access verification based on a user has been moved into ContentController, thus you should use ContentController or implement content access controls yourself. + - require('@openveo/api').databases.factory is now accessible on require('@openveo/api').storages.factory + - require('@openveo/api').databases.Database is now accessible on require('@openveo/api').storages.databases.Database, this is because a new level of abstraction has been added to databases: the Storage. Database now extends Storage. + - EntityController.getEntitiesAction does not return all entities anymore but paginated results. + - EntityController.getEntityAction can now return an HTTP error 404 if entity has not been found. + - EntityController.updateEntityAction and ContentController.updateEntityAction now return property **total** with value **1** if everything went fine. + - EntityController.addEntityAction and ContentController.addEntityAction have been renamed into EntityController.addEntitiesAction and ContentController.addEntitiesAction because it is now possible to add several entities at once. + - EntityController.removeEntityAction and ContentController.removeEntityAction have been renamed into EntityController.removeEntitiesAction and ContentController.removeEntitiesAction because it is now possible to remove several entities at once. + - EntityController.removeEntitiesAction and ContentController.removeEntitiesAction now return property **total** with the number of removed entities. + - ContentController.updateEntityAction can now return an HTTP error 404 if entity has not been found. + - ContentController sub classes need to implement the method isUserManager. + - ContentController.isUserAuthorized now return false if user is not specified. + - ContentController.isUserAuthorized now return true if user is a manager (if ContentController.isUserManager return true). + - ContentController.updateEntityAction authorizes managers to update the entity owner. + - Classes extending EntityController must now implement a getProvider method instead of a getModel method. + - EntityProvider.getOne now expects a ResourceFilter and new fields format. + - EntityProvider.getPaginatedFilteredEntities has been removed, use EntityProvider.get instead. + - EntityProvider.get does not return all entities anymore but paginated results, it expects a ResourceFilter and new fields format. + - EntityProvider.update has been renamed into EntityProvider.updateOne and now expects a ResourceFilter. + - EntityProvider.remove now expects a ResourceFilter. + - EntityProvider.removeProp has been renamed into EntityProvider.removeField and now expects a ResourceFilter. + - EntityProvider.increase has been removed, use EntityProvider.updateOne instead. + - Database.insert has been renamed into Database.add an now expects a ResourceFilter. + - Database.remove now expects a ResourceFilter. + - Database.removeProp has been renamed into Database.removeField and now expects a ResourceFilter. + - Database.update now expects a ResourceFilter. + - Database.get now expects a ResourceFilter and new fields format. + - Database.search has been removed, use Database.get instead. + - Database.increase has been removed, use Database.updateOne instead. + - HTTP error code 512(10) does not correspond anymore to a forbidden error when fetching entities but to a forbidden error when removing entities. + - HTTP error code 515(10) which corresponded to a forbidden error when adding entities has been removed. + +## NEW FEATURES + +- Add cropping parameter to image style definition, image can be cropped when both height & width are specified. +- Add require('@openveo/api').grunt.ngDpTask as a grunt task to analyze an AngularJS application and generate a file containing the list of CSS and JavaScript files respecting the order of AngularJS dependencies. Use it to make sure that your AngularJS files and their associated CSS files are loaded in the right order. Is is based on the premise that the AngularJS application is organiszed in components and sub components +- Add require('@openveo/api').middlewares.imageProcessorMiddleware as an ExpressJS middleware to preprocess images before sending them to the client. Actually only one kind of image manipulation is available: generate a thumbnail +- Add require('@openveo/api').controllers.HttpController which is a new level of abstraction for the EntityController as an EntityController is intimately linked to the HTTP protocol. EntityController now extends HttpController which extends Controller. +- Add fields on require('@openveo/api').controllers.EntityController, require('@openveo/api').controllers.ContentController, require('@openveo/api').providers.EntityProvider and require('@openveo/api').storages.Storage. This lets you precise which entity fields you want to include / exclude from returned entities. +- Add require('@openveo/api').storages.ResourceFilter to be used between controllers, providers and storage to unify the writing of query filters. +- Add EntityProvider.getAll to fetch all entities automatically by requesting pages one by one. This should be used wisely. +- Add Provider.executeCallback as an helper function to execute a callback or log the message if callback is not defined. +- Add require('@openveo/api').storages.databaseErrors holding all error codes relative to databases. +- Add require('@openveo/api').fileSystem.rm to remove either a directory or a file. Use it instead of require('@openveo/api').fileSystem.rmdir. +- Add the notion of content entities manager. Controllers of type ContentController should now implement the method "isUserManager" to indicate if the current user must have the same privileges as the super administrator on the content entities. Managers of content entities are always authorized to perform CRUD operations on a particular type of content entities. + +## BUG FIXES + +- require('@openveo/api').multipart.MultipartParser now removes temporary files if client aborts the request + +# 4.3.1 / 2018-01-16 ## BUG FIXES diff --git a/README.md b/README.md old mode 100755 new mode 100644 index eaef2da..62b272b --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ var api = require('@openveo/api'); # API -Documentation is available on [Github pages](http://veo-labs.github.io/openveo-api/4.3.1/index.html). +Documentation is available on [Github pages](http://veo-labs.github.io/openveo-api/5.0.0/index.html). # Contributors diff --git a/index.js b/index.js index 02bb17e..e85b92f 100644 --- a/index.js +++ b/index.js @@ -11,12 +11,10 @@ process.requireApi = function(filePath) { module.exports.fileSystem = process.requireApi('lib/fileSystem.js'); module.exports.util = process.requireApi('lib/util.js'); module.exports.logger = process.requireApi('lib/logger.js'); - -module.exports.database = process.requireApi('lib/database/index.js'); +module.exports.storages = process.requireApi('lib/storages/index.js'); module.exports.plugin = process.requireApi('lib/plugin/index.js'); module.exports.middlewares = process.requireApi('lib/middlewares/index.js'); module.exports.providers = process.requireApi('lib/providers/index.js'); -module.exports.models = process.requireApi('lib/models/index.js'); module.exports.controllers = process.requireApi('lib/controllers/index.js'); module.exports.errors = process.requireApi('lib/errors/index.js'); module.exports.socket = process.requireApi('lib/socket/index.js'); diff --git a/lib/controllers/ContentController.js b/lib/controllers/ContentController.js index 1df2244..a829620 100644 --- a/lib/controllers/ContentController.js +++ b/lib/controllers/ContentController.js @@ -5,28 +5,33 @@ */ var util = require('util'); +var utilExt = process.requireApi('lib/util.js'); var errors = process.requireApi('lib/controllers/httpErrors.js'); -var AccessError = process.requireApi('lib/errors/AccessError.js'); var EntityController = process.requireApi('lib/controllers/EntityController.js'); +var ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js'); /** * Defines base controller for all controllers which need to provide HTTP route actions for all requests * relative to content entities. * - * // Implement a ContentController : "CustomContentController" - * var util = require('util'); - * var openVeoApi = require('@openveo/api'); + * A content entity is an entity owned by a user, consequently user must be authenticated to use ContentController + * actions. Content entities which belong to the anonymous user can be manipulated by all. Content entities which + * belong to a particular user can be manipulated by this particular user, the super administrator, the entity manager, + * and, if entity is inside a group, by all users which have enough privileges on this group. * - * function CustomContentController() { - * CustomContentController.super_.call(this); - * } + * The authenticated user must have the following properties: + * - **String** id The user id + * - **Array** permissions An array of permissions in the following format: OPERATION-group-GROUP_ID, where OPERATION + * is one of ContentController.OPERATIONS and GROUP_ID the id of a group (e.g. + * ['get-group-Jekrn20Rl', 'update-group-Jekrn20Rl', 'delete-group-YldO3Jie3']) * - * util.inherits(CustomContentController, openVeoApi.controllers.ContentController); + * A content entity has a "metadata" property with: + * - **String** user The id of the content entity owner + * - **Array** groups The list of groups associated to the content entity * * @class ContentController * @extends EntityController * @constructor - * @throws {TypeError} An error if constructors are not as expected */ function ContentController() { ContentController.super_.call(this); @@ -35,40 +40,169 @@ function ContentController() { module.exports = ContentController; util.inherits(ContentController, EntityController); +// Operations on content entities + +/** + * The list of operations used to manage privileges of a user. + * + * @property OPERATIONS + * @type Object + * @final + * @static + */ +ContentController.OPERATIONS = { + READ: 'get', + UPDATE: 'update', + DELETE: 'delete' +}; +Object.freeze(ContentController.OPERATIONS); + /** - * Gets the list of entities. + * Gets user permissions by groups. + * + * @example + * + * // Example of user permissions + * ['get-group-Jekrn20Rl', 'update-group-Jekrn20Rl', 'delete-group-YldO3Jie3'] + * + * // Example of returned groups + * { + * 'Jekrn20Rl': ['get', 'update'], // User only has get / update permissions on group 'Jekrn20Rl' + * 'YldO3Jie3': ['delete'], // User only has delete permission on group 'YldO3Jie3' + * ... + * } + * + * @method getUserGroups + * @private + * @param {Object} user The user to extract groups from + * @return {Object} Groups organized by ids + */ +function getUserGroups(user) { + var groups = {}; + + if (user && user.permissions) { + user.permissions.forEach(function(permission) { + var reg = new RegExp('^(get|update|delete)-group-(.+)$'); + var permissionChunks = reg.exec(permission); + if (permissionChunks) { + var operation = permissionChunks[1]; + var groupId = permissionChunks[2]; + + if (!groups[groupId]) + groups[groupId] = []; + + groups[groupId].push(operation); + } + }); + } + + return groups; +} + +/** + * Gets the list of groups of a user, with authorization on a certain operation. + * + * All user groups with authorization on the operation are returned. + * + * @method getUserAuthorizedGroups + * @private + * @param {Object} user The user + * @param {String} operation The operation (get, update or delete) + * @return {Array} The list of user groups which have authorization on the given operation + */ +function getUserAuthorizedGroups(user, operation) { + var userGroups = getUserGroups(user); + var groups = []; + + for (var groupId in userGroups) { + if (userGroups[groupId].indexOf(operation) >= 0) + groups.push(groupId); + } + + return groups; +} + +/** + * Gets entities. + * + * If user does not have enough privilege to read a particular entity, the entity is not listed in the response. * * @example * * // Response example * { - * "entities" : [ ... ] + * "entities" : [ ... ], + * "pagination" : { + * "limit": ..., // The limit number of entities by page + * "page": ..., // The actual page + * "pages": ..., // The total number of pages + * "size": ... // The total number of entities * } * * @method getEntitiesAction * @async * @param {Request} request ExpressJS HTTP Request + * @param {Object} request.query Request query + * @param {String|Array} [request.query.include] The list of fields to include from returned entities + * @param {String|Array} [request.query.exclude] The list of fields to exclude from returned entities. Ignored if + * include is also specified. + * @param {Number} [request.query.limit] A limit number of entities to retrieve per page (default to 10) + * @param {Number} [request.query.page] The page number started at 0 for the first page (default to 0) + * @param {String} [request.query.sortBy] The entity field to sort by + * @param {String} [request.query.sortOrder] Either "asc" for ascendant or "desc" for descendant * @param {Response} response ExpressJS HTTP Response * @param {Function} next Function to defer execution to the next registered middleware */ ContentController.prototype.getEntitiesAction = function(request, response, next) { - var model = this.getModel(request); - - model.get(null, function(error, entities) { - if (error) { - process.logger.error(error.message, {error: error, method: 'getEntitiesAction'}); - next((error instanceof AccessError) ? errors.GET_ENTITIES_FORBIDDEN : errors.GET_ENTITIES_ERROR); - } else { - response.send({ - entities: entities - }); + var provider = this.getProvider(); + var sort = {}; + var query; + request.query = request.query || {}; + + try { + query = utilExt.shallowValidateObject(request.query, { + include: {type: 'array'}, + exclude: {type: 'array'}, + limit: {type: 'number', gt: 0, default: 10}, + page: {type: 'number', gte: 0, default: 0}, + sortBy: {type: 'string'}, + sortOrder: {type: 'string', in: ['asc', 'desc'], default: 'desc'} + }); + } catch (error) { + return next(errors.GET_ENTITIES_WRONG_PARAMETERS); + } + + // Build sort description object + if (query.sortBy && query.sortOrder) sort[query.sortBy] = query.sortOrder; + + provider.get( + this.addAccessFilter(null, request.user), + { + exclude: query.exclude, + include: query.include + }, + query.limit, + query.page, + sort, + function(error, entities, pagination) { + if (error) { + process.logger.error(error.message, {error: error, method: 'getEntitiesAction'}); + next(errors.GET_ENTITIES_ERROR); + } else { + response.send({ + entities: entities, + pagination: pagination + }); + } } - }); + ); }; /** * Gets a specific entity. * + * User must have permission to read the entity. + * * @example * * // Response example @@ -79,29 +213,58 @@ ContentController.prototype.getEntitiesAction = function(request, response, next * @method getEntityAction * @async * @param {Request} request ExpressJS HTTP Request - * @param {Object} request.params Request's parameters + * @param {Object} request.params Request parameters * @param {String} request.params.id The entity id to retrieve + * @param {Object} request.query Request query + * @param {String|Array} [request.query.include] The list of fields to include from returned entity + * @param {String|Array} [request.query.exclude] The list of fields to exclude from returned entity. Ignored if + * include is also specified. * @param {Response} response ExpressJS HTTP Response * @param {Function} next Function to defer execution to the next registered middleware */ ContentController.prototype.getEntityAction = function(request, response, next) { if (request.params.id) { var entityId = request.params.id; - var model = this.getModel(request); + var provider = this.getProvider(); + var self = this; + var fields; + request.query = request.query || {}; - model.getOne(entityId, null, function(error, entity) { - if (error) { - process.logger.error(error.message, {error: error, method: 'getEntityAction', entity: entityId}); - next((error instanceof AccessError) ? errors.GET_ENTITY_FORBIDDEN : errors.GET_ENTITY_ERROR); - } else if (!entity) { - process.logger.warn('Not found', {method: 'getEntityAction', entity: entityId}); - next(errors.GET_ENTITY_NOT_FOUND); - } else { - response.send({ - entity: entity - }); + try { + fields = utilExt.shallowValidateObject(request.query, { + include: {type: 'array'}, + exclude: {type: 'array'} + }); + } catch (error) { + return next(errors.GET_ENTITY_WRONG_PARAMETERS); + } + + // Make sure "metadata" field is not excluded + fields = this.removeMetatadaFromFields(fields); + + provider.getOne( + new ResourceFilter().equal('id', entityId), + fields, + function(error, entity) { + if (error) { + process.logger.error(error.message, {error: error, method: 'getEntityAction', entity: entityId}); + next(errors.GET_ENTITY_ERROR); + } else if (!entity) { + process.logger.warn('Not found', {method: 'getEntityAction', entity: entityId}); + next(errors.GET_ENTITY_NOT_FOUND); + } else if (!self.isUserAuthorized(request.user, entity, ContentController.OPERATIONS.READ)) { + process.logger.error( + 'User "' + request.user.id + '" doesn\'t have access to entity "' + entityId + '"', + {method: 'getEntityAction'} + ); + next(errors.GET_ENTITY_FORBIDDEN); + } else { + response.send({ + entity: entity + }); + } } - }); + ); } else { // Missing id of the entity @@ -113,77 +276,416 @@ ContentController.prototype.getEntityAction = function(request, response, next) /** * Updates an entity. * + * User must have permission to update the entity. If user doesn't have permission to update the entity an + * HTTP forbidden error will be sent as response. + * * @example * - * // Expected body example + * // Response example * { - * // Entity's data + * "total": 1 * } * * @method updateEntityAction * @async * @param {Request} request ExpressJS HTTP Request - * @param {Object} request.params Request's parameters - * @param {String} request.params.id The entity id to update + * @param {Object} request.params Request parameters + * @param {String} request.params.id The id of the entity to update + * @param {Object} request.body The fields to update with their values + * @param {Array} [request.body.groups] The list of groups the content entity belongs to + * @param {String} [request.body.user] The id of the entity owner. Only the owner can modify the entity owner. * @param {Response} response ExpressJS HTTP Response * @param {Function} next Function to defer execution to the next registered middleware */ ContentController.prototype.updateEntityAction = function(request, response, next) { if (request.params.id && request.body) { + var self = this; var entityId = request.params.id; - var model = this.getModel(request); + var provider = this.getProvider(); + var data = request.body; + var metadatas; + + try { + metadatas = utilExt.shallowValidateObject(request.body, { + groups: {type: 'array'}, + user: {type: 'string'} + }); + } catch (error) { + return next(errors.UPDATE_ENTITY_WRONG_PARAMETERS); + } + + if (metadatas.groups) { + data['metadata.groups'] = data.groups.filter(function(group) { + return group ? true : false; + }); + } + + if (metadatas.user) data['metadata.user'] = data.user; + + // Get information on the entity which is about to be updated to validate that the user has enough permissions + // to update it + provider.getOne( + new ResourceFilter().equal('id', entityId), + { + include: ['id', 'metadata'] + }, + function(error, entity) { + if (error) return next(errors.UPDATE_ENTITY_GET_ONE_ERROR); + if (!entity) return next(errors.UPDATE_ENTITY_NOT_FOUND_ERROR); + + // Make sure user is authorized to modify all the entities + if (self.isUserAuthorized(request.user, entity, ContentController.OPERATIONS.UPDATE)) { + + // User has permission to update this entity + + // User is authorized to update the entity but he must be owner to update the owner + if (!self.isUserOwner(entity, request.user) && + !self.isUserAdmin(request.user) && + !self.isUserManager(request.user)) { + delete data['user']; + } + + provider.updateOne(new ResourceFilter().equal('id', entity.id), data, function(error, total) { + if (error) { + process.logger.error(error.message || 'Fail updating', + {method: 'updateEntityAction', entity: entityId}); + next(errors.UPDATE_ENTITY_ERROR); + } else if (!total) { + process.logger.error('The entity could not be updated', + {method: 'updateEntityAction', entity: entityId}); + next(errors.UPDATE_ENTITY_ERROR); + } else { + response.send({total: total}); + } + }); + } else { + process.logger.error('The entity could not be updated', {method: 'updateEntityAction', entity: entityId}); + next(errors.UPDATE_ENTITY_FORBIDDEN); + } - model.update(entityId, request.body, function(error, updateCount) { - if (error) { - process.logger.error((error && error.message) || 'Fail updating', - {method: 'updateEntityAction', entity: entityId}); - next((error instanceof AccessError) ? errors.UPDATE_ENTITY_FORBIDDEN : errors.UPDATE_ENTITY_ERROR); - } else { - response.send({error: null, status: 'ok'}); } - }); + ); + } else { - // Missing id of the entity or the datas + // Missing entity id or the datas next(errors.UPDATE_ENTITY_MISSING_PARAMETERS); } }; /** - * Adds an entity. + * Adds entities. + * + * Information about the user (which becomes the owner) is automatically added to the entities. * * @example * - * // Expected body example + * // Response example * { - * "entity" : { ... } + * "entities": [ ... ], + * "total": 42 * } * - * @method addEntityAction + * @method addEntitiesAction * @async * @param {Request} request ExpressJS HTTP Request + * @param {Array} request.body The list of entities to add with for each entity the fields with their values + * @param {Array} [request.body.groups] The list of groups the content entities belong to * @param {Response} response ExpressJS HTTP Response * @param {Function} next Function to defer execution to the next registered middleware */ -ContentController.prototype.addEntityAction = function(request, response, next) { +ContentController.prototype.addEntitiesAction = function(request, response, next) { if (request.body) { - var model = this.getModel(request); + var provider = this.getProvider(); + var parsedRequest; + var datas; + + try { + parsedRequest = utilExt.shallowValidateObject(request, { + body: {type: 'array', required: true} + }); + } catch (error) { + return next(errors.ADD_ENTITIES_WRONG_PARAMETERS); + } + + // Set common content entities information + datas = parsedRequest.body; + datas.forEach(function(data) { + data.metadata = { + user: request.user && request.user.id, + groups: data.groups || [] + }; + }); - model.add(request.body, function(error, insertCount, entity) { + provider.add(datas, function(error, total, entities) { if (error) { - process.logger.error(error.message, {error: error, method: 'addEntityAction'}); - next((error instanceof AccessError) ? errors.ADD_ENTITY_FORBIDDEN : errors.ADD_ENTITY_ERROR); - } else { - response.send({ - entity: entity - }); - } + process.logger.error(error.message, {error: error, method: 'addEntitiesAction'}); + next(errors.ADD_ENTITIES_ERROR); + } else + response.send({entities: entities, total: total}); }); } else { // Missing body - next(errors.ADD_ENTITY_MISSING_PARAMETERS); + next(errors.ADD_ENTITIES_MISSING_PARAMETERS); + + } +}; + +/** + * Removes entities. + * + * User must have permission to remove the entities. If user doesn't have permission to remove a particular entity an + * HTTP forbidden error will be sent as response and there won't be any guarantee on the number of removed entities. + * + * @example + * + * // Response example + * { + * "total": 42 + * } + * + * @method removeEntitiesAction + * @async + * @param {Request} request ExpressJS HTTP Request + * @param {Object} request.params Request parameters + * @param {String} request.params.id A comma separated list of entity ids to remove + * @param {Response} response ExpressJS HTTP Response + * @param {Function} next Function to defer execution to the next registered middleware + */ +ContentController.prototype.removeEntitiesAction = function(request, response, next) { + if (request.params.id) { + var self = this; + var entityIds = request.params.id.split(','); + var entityIdsToRemove = []; + var provider = this.getProvider(); + + // Get information on entities which are about to be removed to validate that the user has enough permissions + // to do it + provider.get( + new ResourceFilter().in('id', entityIds), + { + include: ['id', 'metadata'] + }, + entityIds.length, + null, + null, + function(error, entities, pagination) { + + // Make sure user is authorized to modify all the entities + entities.forEach(function(entity) { + if (self.isUserAuthorized(request.user, entity, ContentController.OPERATIONS.DELETE)) + entityIdsToRemove.push(entity.id); + }); + + if (entityIdsToRemove.length !== entityIds.length) { + process.logger.error( + 'Some entities can\'t be removed : abort', + {method: 'removeEntitiesAction', entities: entityIds, removedEntities: entityIdsToRemove} + ); + return next(errors.REMOVE_ENTITIES_FORBIDDEN); + } + + provider.remove(new ResourceFilter().in('id', entityIdsToRemove), function(error, total) { + if (error) { + process.logger.error(error.message, {error: error, method: 'removeEntitiesAction'}); + next(errors.REMOVE_ENTITIES_ERROR); + } else if (total != entityIdsToRemove.length) { + process.logger.error(total + '/' + entityIds.length + ' removed', + {method: 'removeEntitiesAction', entities: entityIdsToRemove}); + next(errors.REMOVE_ENTITIES_ERROR); + } else { + response.send({total: total}); + } + }); + + } + ); + + } else { + + // Missing entity ids + next(errors.REMOVE_ENTITIES_MISSING_PARAMETERS); + + } +}; + +/** + * Adds access rules to the given filter reference. + * + * Access rules make sure that content entities belong to the user (owner or in the same group). + * If no filter is specified, a new filter is created. + * + * @method addAccessFilter + * @param {ResourceFilter} [filter] The filter to add the access rules to + * @param {Object} user The user information + * @param {String} user.id The user id + * @param {Array} user.permissions The user permissions + * @return {ResourceFilter} The modified filter or a new one if no filter specified + */ +ContentController.prototype.addAccessFilter = function(filter, user) { + if (user && !this.isUserAdmin(user) && !this.isUserManager(user)) { + var userGroups = getUserAuthorizedGroups(user, ContentController.OPERATIONS.READ); + + if (!filter) filter = new ResourceFilter(); + + filter.or([ + new ResourceFilter().in('metadata.user', [user.id, this.getAnonymousId()]) + ]); + + if (userGroups.length) { + filter.or([ + new ResourceFilter().in('metadata.groups', userGroups) + ]); + } + } + + return filter; +}; + +/** + * Tests if user is the administrator. + * + * @method isUserAdmin + * @param {Object} user The user to test + * @param {String} user.id The user's id + * @return {Boolean} true if the user is the administrator, false otherwise + */ +ContentController.prototype.isUserAdmin = function(user) { + return user && user.id === this.getSuperAdminId(); +}; + +/** + * Tests if user is the anonymous user. + * + * @method isUserAnonymous + * @param {Object} user The user to test + * @param {String} user.id The user's id + * @return {Boolean} true if the user is the anonymous, false otherwise + */ +ContentController.prototype.isUserAnonymous = function(user) { + return user && user.id === this.getAnonymousId(); +}; +/** + * Tests if user is the owner of a content entity. + * + * @method isUserOwner + * @param {Object} entity The entity to test + * @param {Object} entity.metadata Entity information about associated user and groups + * @param {String} entity.metadata.user The id of the user the entity belongs to + * @param {Object} user The user to test + * @param {String} user.id The user's id + * @return {Boolean} true if the user is the owner, false otherwise + */ +ContentController.prototype.isUserOwner = function(entity, user) { + return user && entity.metadata && entity.metadata.user === user.id; +}; + +/** + * Validates that a user is authorized to manipulate a content entity. + * + * User is authorized to manipulate the entity if one of the following conditions is met: + * - The entity belongs to the anonymous user + * - User is the super administrator + * - User is the owner of the entity + * - User has permission to manage contents + * - Entity has associated groups and user has permission to perform the operation on one of these groups + * + * @method isUserAuthorized + * @param {Object} user The user + * @param {String} user.id The user's id + * @param {Array} user.permissions The user's permissions + * @param {Object} entity The entity to manipulate + * @param {Object} entity.metadata Entity information about associated user and groups + * @param {String} entity.metadata.user The id of the user the entity belongs to + * @param {Array} entity.metadata.groups The list of group ids the entity is part of + * @param {String} operation The operation to perform on the entity + * @return {Boolean} true if the user can manipulate the entity, false otherwise + */ +ContentController.prototype.isUserAuthorized = function(user, entity, operation) { + if (this.isUserAdmin(user) || + this.isUserManager(user) || + this.isUserOwner(entity, user) || + (entity.metadata && this.isUserAnonymous({id: entity.metadata.user})) + ) { + return true; } + if (entity.metadata && entity.metadata.groups) { + var userGroups = getUserAuthorizedGroups(user, operation); + return utilExt.intersectArray(entity.metadata.groups, userGroups).length; + } + + return false; +}; + +/** + * Removes "metadata" field from query fields. + * + * The "metadata" property of a content entity is used by ContentControllers to validate that a user + * has enough privileges to perform an action. "metadata" property contains the id of the user the content property + * belongs to and the list of groups the entity is part of. + * Consequently "metadata" property has to be fetched by the provider when getting an entity, however we authorize the + * user the exclude / include fields from provider response. removeMetadataFromFields makes sure "metadata" property + * is not excluded from returned fields. + * + * @method removeMetatadaFromFields + * @param {Object} fields The include and exclude fields + * @param {Array} [fields.include] The list of fields to include which may contain a "metadata" property + * @param {Array} [fields.exclude] The list of fields to exclude may contain a "metadata" property + * @return {Object} The same fields object with new include and exclude arrays + */ +ContentController.prototype.removeMetatadaFromFields = function(fields) { + if (fields.exclude) { + fields.exclude = fields.exclude.filter(function(text) { + return text.indexOf('metadata') < 0; + }); + } + + if (fields.include) fields.include.push('metadata'); + + return fields; +}; + +/** + * Gets the id of the super administrator. + * + * It must be overriden by the sub class. + * + * @method getSuperAdminId + * @return {String} The id of the super admin + * @throw {Error} getSuperAdminId is not implemented + */ +ContentController.prototype.getSuperAdminId = function() { + throw new Error('getSuperAdminId is not implemented for this ContentController'); +}; + +/** + * Gets the id of the anonymous user. + * + * It must be overriden by the sub class. + * + * @method getAnonymousId + * @return {String} The id of the anonymous user + * @throw {Error} getAnonymousId is not implemented + */ +ContentController.prototype.getAnonymousId = function() { + throw new Error('getAnonymousId is not implemented for this ContentController'); +}; + +/** + * Tests if user is a contents manager. + * + * A contents manager can perform CRUD operations on content entities. + * It must be overriden by the sub class. + * + * @method isUserManager + * @param {Object} user The user to test + * @param {Array} user.permissions The user's permissions + * @return {Boolean} true if the user has permission to manage contents, false otherwise + * @throw {Error} isUserManager is not implemented + */ +ContentController.prototype.isUserManager = function(user) { + throw new Error('isUserManager is not implemented for this ContentController'); }; diff --git a/lib/controllers/Controller.js b/lib/controllers/Controller.js index e1b31cc..9305215 100644 --- a/lib/controllers/Controller.js +++ b/lib/controllers/Controller.js @@ -7,16 +7,6 @@ /** * Defines base controller for all controllers. * - * // Implement a Controller : "CustomController" - * var util = require('util'); - * var openVeoApi = require('@openveo/api'); - * - * function CustomController() { - * CustomController.super_.call(this); - * } - * - * util.inherits(CustomController, openVeoApi.controllers.Controller); - * * @class Controller * @constructor */ diff --git a/lib/controllers/EntityController.js b/lib/controllers/EntityController.js old mode 100755 new mode 100644 index afbda6e..669e34a --- a/lib/controllers/EntityController.js +++ b/lib/controllers/EntityController.js @@ -5,25 +5,17 @@ */ var util = require('util'); -var Controller = process.requireApi('lib/controllers/Controller.js'); +var utilExt = process.requireApi('lib/util.js'); +var HttpController = process.requireApi('lib/controllers/HttpController.js'); +var ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js'); var errors = process.requireApi('lib/controllers/httpErrors.js'); /** * Defines base controller for all controllers which need to provide HTTP route actions for all requests * relative to entities. * - * // Implement an EntityController : "CustomEntityController" - * var util = require('util'); - * var openVeoApi = require('@openveo/api'); - * - * function CustomEntityController(model, provider) { - * CustomEntityController.super_.call(this, model, provider); - * } - * - * util.inherits(CustomEntityController, openVeoApi.controllers.EntityController); - * * @class EntityController - * @extends Controller + * @extends HttpController * @constructor */ function EntityController() { @@ -31,37 +23,80 @@ function EntityController() { } module.exports = EntityController; -util.inherits(EntityController, Controller); +util.inherits(EntityController, HttpController); /** - * Gets the list of entities. + * Gets entities. * * @example * * // Response example * { - * "entities" : [ ... ] + * "entities" : [ ... ], + * "pagination" : { + * "limit": ..., // The limit number of entities by page + * "page": ..., // The actual page + * "pages": ..., // The total number of pages + * "size": ... // The total number of entities * } * * @method getEntitiesAction * @async * @param {Request} request ExpressJS HTTP Request + * @param {Object} [request.query] Request query + * @param {String|Array} [request.query.include] The list of fields to include from returned entities + * @param {String|Array} [request.query.exclude] The list of fields to exclude from returned entities. Ignored if + * include is also specified. + * @param {Number} [request.query.limit] A limit number of entities to retrieve per page (default to 10) + * @param {Number} [request.query.page] The page number started at 0 for the first page (default to 0) + * @param {String} [request.query.sortBy] The entity field to sort by + * @param {String} [request.query.sortOrder] Either "asc" for ascendant or "desc" for descendant * @param {Response} response ExpressJS HTTP Response * @param {Function} next Function to defer execution to the next registered middleware */ EntityController.prototype.getEntitiesAction = function(request, response, next) { - var model = this.getModel(request); - - model.get(null, function(error, entities) { - if (error) { - process.logger.error(error.message, {error: error, method: 'getEntitiesAction'}); - next(errors.GET_ENTITIES_ERROR); - } else { - response.send({ - entities: entities - }); + var provider = this.getProvider(); + var sort = {}; + var query; + request.query = request.query || {}; + + try { + query = utilExt.shallowValidateObject(request.query, { + include: {type: 'array'}, + exclude: {type: 'array'}, + limit: {type: 'number', gt: 0, default: 10}, + page: {type: 'number', gte: 0, default: 0}, + sortBy: {type: 'string'}, + sortOrder: {type: 'string', in: ['asc', 'desc'], default: 'desc'} + }); + } catch (error) { + return next(errors.GET_ENTITIES_WRONG_PARAMETERS); + } + + // Build sort description object + if (query.sortBy && query.sortOrder) sort[query.sortBy] = query.sortOrder; + + provider.get( + null, + { + exclude: query.exclude, + include: query.include + }, + query.limit, + query.page, + sort, + function(error, entities, pagination) { + if (error) { + process.logger.error(error.message, {error: error, method: 'getEntitiesAction'}); + next(errors.GET_ENTITIES_ERROR); + } else { + response.send({ + entities: entities, + pagination: pagination + }); + } } - }); + ); }; /** @@ -77,29 +112,50 @@ EntityController.prototype.getEntitiesAction = function(request, response, next) * @method getEntityAction * @async * @param {Request} request ExpressJS HTTP Request - * @param {Object} request.params Request's parameters + * @param {Object} request.params Request parameters * @param {String} request.params.id The entity id to retrieve + * @param {Object} request.query Request query + * @param {String|Array} [request.query.include] The list of fields to include from returned entity + * @param {String|Array} [request.query.exclude] The list of fields to exclude from returned entity. Ignored if + * include is also specified. * @param {Response} response ExpressJS HTTP Response * @param {Function} next Function to defer execution to the next registered middleware */ EntityController.prototype.getEntityAction = function(request, response, next) { if (request.params.id) { var entityId = request.params.id; - var model = this.getModel(request); + var provider = this.getProvider(); + var query; + request.query = request.query || {}; - model.getOne(entityId, null, function(error, entity) { - if (error) { - process.logger.error(error.message, {error: error, method: 'getEntityAction', entity: entityId}); - next(errors.GET_ENTITY_ERROR); - } else if (!entity) { - process.logger.warn('Not found', {method: 'getEntityAction', entity: entityId}); - next(errors.GET_ENTITY_NOT_FOUND); - } else { - response.send({ - entity: entity - }); + try { + query = utilExt.shallowValidateObject(request.query, { + include: {type: 'array'}, + exclude: {type: 'array'} + }); + } catch (error) { + return next(errors.GET_ENTITY_WRONG_PARAMETERS); + } + + provider.getOne( + new ResourceFilter().equal('id', entityId), + { + exclude: query.exclude, + include: query.include + }, function(error, entity) { + if (error) { + process.logger.error(error.message, {error: error, method: 'getEntityAction', entity: entityId}); + next(errors.GET_ENTITY_ERROR); + } else if (!entity) { + process.logger.warn('Not found', {method: 'getEntityAction', entity: entityId}); + next(errors.GET_ENTITY_NOT_FOUND); + } else { + response.send({ + entity: entity + }); + } } - }); + ); } else { // Missing id of the entity @@ -113,75 +169,89 @@ EntityController.prototype.getEntityAction = function(request, response, next) { * * @example * - * // Expected body example + * // Response example * { - * // Entity's data + * "total": 1 * } * * @method updateEntityAction * @async * @param {Request} request ExpressJS HTTP Request - * @param {Object} request.params Request's parameters - * @param {String} request.params.id The entity id to update + * @param {Object} request.params Request parameters + * @param {String} request.params.id The id of the entity to update + * @param {Object} request.body The fields to update with their values * @param {Response} response ExpressJS HTTP Response * @param {Function} next Function to defer execution to the next registered middleware */ EntityController.prototype.updateEntityAction = function(request, response, next) { if (request.params.id && request.body) { var entityId = request.params.id; - var model = this.getModel(request); + var provider = this.getProvider(); - model.update(entityId, request.body, function(error, updateCount) { - if (error) { - process.logger.error((error && error.message) || 'Fail updating', - {method: 'updateEntityAction', entity: entityId}); - next(errors.UPDATE_ENTITY_ERROR); - } else { - response.send({error: null, status: 'ok'}); + provider.updateOne( + new ResourceFilter().equal('id', entityId), + request.body, + function(error, total) { + if (error) { + process.logger.error(error.message || 'Fail updating', + {method: 'updateEntityAction', entities: entityId}); + next(errors.UPDATE_ENTITY_ERROR); + } else { + response.send({total: total}); + } } - }); + ); } else { - // Missing id of the entity or the datas + // Missing entity ids or the datas next(errors.UPDATE_ENTITY_MISSING_PARAMETERS); } }; /** - * Adds an entity. + * Adds entities. * * @example * - * // Expected body example + * // Response example * { - * "entity" : { ... } + * "entities": [ ... ], + * "total": 42 * } * - * @method addEntityAction + * @method addEntitiesAction * @async * @param {Request} request ExpressJS HTTP Request + * @param {Array} request.body The list of entities to add with for each entity the fields with their values * @param {Response} response ExpressJS HTTP Response * @param {Function} next Function to defer execution to the next registered middleware */ -EntityController.prototype.addEntityAction = function(request, response, next) { +EntityController.prototype.addEntitiesAction = function(request, response, next) { if (request.body) { - var model = this.getModel(request); + var provider = this.getProvider(request); + var parsedRequest; - model.add(request.body, function(error, insertCount, entity) { + try { + parsedRequest = utilExt.shallowValidateObject(request, { + body: {type: 'array', required: true} + }); + } catch (error) { + return next(errors.ADD_ENTITIES_WRONG_PARAMETERS); + } + + provider.add(parsedRequest.body, function(error, total, entities) { if (error) { - process.logger.error(error.message, {error: error, method: 'addEntityAction'}); - next(errors.ADD_ENTITY_ERROR); + process.logger.error(error.message, {error: error, method: 'addEntitiesAction'}); + next(errors.ADD_ENTITIES_ERROR); } else { - response.send({ - entity: entity - }); + response.send({entities: entities, total: total}); } }); } else { // Missing body - next(errors.ADD_ENTITY_MISSING_PARAMETERS); + next(errors.ADD_ENTITIES_MISSING_PARAMETERS); } }; @@ -189,46 +259,56 @@ EntityController.prototype.addEntityAction = function(request, response, next) { /** * Removes entities. * - * @method removeEntityAction + * @example + * + * // Response example + * { + * "total": 42 + * } + * + * @method removeEntitiesAction * @async * @param {Request} request ExpressJS HTTP Request - * @param {Object} request.params Request's parameters - * @param {String} request.params.id The comma separated list of entity ids to remove + * @param {Object} request.params Request parameters + * @param {String} request.params.id A comma separated list of entity ids to remove * @param {Response} response ExpressJS HTTP Response * @param {Function} next Function to defer execution to the next registered middleware */ -EntityController.prototype.removeEntityAction = function(request, response, next) { +EntityController.prototype.removeEntitiesAction = function(request, response, next) { if (request.params.id) { - var arrayId = request.params.id.split(','); - var model = this.getModel(request); + var entityIds = request.params.id.split(','); + var provider = this.getProvider(request); - model.remove(arrayId, function(error, deleteCount) { - if (error) { - process.logger.error(error.message, {error: error, method: 'removeEntityAction'}); - next(errors.REMOVE_ENTITY_ERROR); - } else if (deleteCount != arrayId.length) { - process.logger.error(deleteCount + '/' + arrayId.length + ' removed', - {method: 'removeEntityAction', ids: arrayId}); - next(errors.REMOVE_ENTITY_ERROR); - } else { - response.send({error: null, status: 'ok'}); + provider.remove( + new ResourceFilter().in('id', entityIds), + function(error, total) { + if (error) { + process.logger.error(error.message, {error: error, method: 'removeEntitiesAction'}); + next(errors.REMOVE_ENTITIES_ERROR); + } else if (total != entityIds.length) { + process.logger.error(total + '/' + entityIds.length + ' removed', + {method: 'removeEntitiesAction', entities: entityIds}); + next(errors.REMOVE_ENTITIES_ERROR); + } else { + response.send({total: total}); + } } - }); + ); } else { - // Missing id of the entity - next(errors.REMOVE_ENTITY_MISSING_PARAMETERS); + // Missing entity ids + next(errors.REMOVE_ENTITIES_MISSING_PARAMETERS); } }; /** - * Gets an instance of the entity model associated to the controller. + * Gets an instance of the entity provider associated to the controller. * - * @method getModel - * @return {EntityModel} The entity model - * @throws {Error} getModel not implemented for this EntityController + * @method getProvider + * @return {EntityProvider} The entity provider + * @throws {Error} getProvider not implemented for this EntityController */ -EntityController.prototype.getModel = function() { - throw new Error('getModel not implemented for this EntityController'); +EntityController.prototype.getProvider = function() { + throw new Error('getProvider not implemented for this EntityController'); }; diff --git a/lib/controllers/HttpController.js b/lib/controllers/HttpController.js new file mode 100644 index 0000000..4124ad8 --- /dev/null +++ b/lib/controllers/HttpController.js @@ -0,0 +1,22 @@ +'use strict'; + +/** + * @module controllers + */ + +var util = require('util'); +var Controller = process.requireApi('lib/controllers/Controller.js'); + +/** + * Defines base controller for all controllers which need to provide HTTP route actions for HTTP requests. + * + * @class HttpController + * @extends Controller + * @constructor + */ +function HttpController() { + HttpController.super_.call(this); +} + +module.exports = HttpController; +util.inherits(HttpController, Controller); diff --git a/lib/controllers/httpErrors.js b/lib/controllers/httpErrors.js index c8f2798..f9acebd 100644 --- a/lib/controllers/httpErrors.js +++ b/lib/controllers/httpErrors.js @@ -56,13 +56,13 @@ var HTTP_ERRORS = { }, /** - * A server error occurring when adding an entity. + * A server error occurring when adding entities. * - * @property ADD_ENTITY_ERROR + * @property ADD_ENTITIES_ERROR * @type Object * @final */ - ADD_ENTITY_ERROR: { + ADD_ENTITIES_ERROR: { code: 0x003, httpCode: 500, module: 'api' @@ -71,16 +71,29 @@ var HTTP_ERRORS = { /** * A server error occurring when removing an entity. * - * @property REMOVE_ENTITY_ERROR + * @property REMOVE_ENTITIES_ERROR * @type Object * @final */ - REMOVE_ENTITY_ERROR: { + REMOVE_ENTITIES_ERROR: { code: 0x004, httpCode: 500, module: 'api' }, + /** + * Updating entity failed when getting the entity. + * + * @property UPDATE_ENTITY_GET_ONE_ERROR + * @type Object + * @final + */ + UPDATE_ENTITY_GET_ONE_ERROR: { + code: 0x005, + httpCode: 500, + module: 'api' + }, + // Not found errors /** @@ -96,17 +109,29 @@ var HTTP_ERRORS = { module: 'api' }, + /** + * Entity was not found when trying to update it. + * + * @property UPDATE_ENTITY_NOT_FOUND_ERROR + * @type Object + * @final + */ + UPDATE_ENTITY_NOT_FOUND_ERROR: { + code: 0x101, + httpCode: 404, + module: 'api' + }, + // Authentication errors /** - * An authentication error occurring when the connected user doesn't have enough privileges - * to get the list of entities. + * An authentication error occurring when the connected user doesn't have enough privileges to remove entities. * - * @property GET_ENTITIES_FORBIDDEN + * @property REMOVE_ENTITIES_FORBIDDEN * @type Object * @final */ - GET_ENTITIES_FORBIDDEN: { + REMOVE_ENTITIES_FORBIDDEN: { code: 0x200, httpCode: 403, module: 'api' @@ -138,23 +163,10 @@ var HTTP_ERRORS = { module: 'api' }, - /** - * An authentication error occurring when the connected user doesn't have enough privileges to add an entity. - * - * @property ADD_ENTITY_FORBIDDEN - * @type Object - * @final - */ - ADD_ENTITY_FORBIDDEN: { - code: 0x203, - httpCode: 403, - module: 'api' - }, - // Wrong parameters errors /** - * A wrong parameter error occurring when a parameter is missing while getting an entity. + * A wrong parameters error occurring when a parameter is missing while getting an entity. * * @property GET_ENTITY_MISSING_PARAMETERS * @type Object @@ -167,7 +179,7 @@ var HTTP_ERRORS = { }, /** - * A wrong parameter error occurring when a parameter is missing while updating an entity. + * A wrong parameters error occurring when a parameter is missing while updating an entity. * * @property UPDATE_ENTITY_MISSING_PARAMETERS * @type Object @@ -180,29 +192,81 @@ var HTTP_ERRORS = { }, /** - * A wrong parameter error occurring when a parameter is missing while adding an entity. + * A wrong parameters error occurring when a parameter is missing while adding entities. * - * @property ADD_ENTITY_MISSING_PARAMETERS + * @property ADD_ENTITIES_MISSING_PARAMETERS * @type Object * @final */ - ADD_ENTITY_MISSING_PARAMETERS: { + ADD_ENTITIES_MISSING_PARAMETERS: { code: 0x302, httpCode: 400, module: 'api' }, /** - * A wrong parameter error occurring when a parameter is missing while removing an entity. + * A wrong parameters error occurring when a parameter is missing while removing entities. * - * @property REMOVE_ENTITY_MISSING_PARAMETERS + * @property REMOVE_ENTITIES_MISSING_PARAMETERS * @type Object * @final */ - REMOVE_ENTITY_MISSING_PARAMETERS: { + REMOVE_ENTITIES_MISSING_PARAMETERS: { code: 0x303, httpCode: 400, module: 'api' + }, + + /** + * Getting the list of entities failed, wrong parameters. + * + * @property GET_ENTITIES_WRONG_PARAMETERS + * @type Object + * @final + */ + GET_ENTITIES_WRONG_PARAMETERS: { + code: 0x304, + httpCode: 400, + module: 'api' + }, + + /** + * Getting an entity failed, wrong parameters. + * + * @property GET_ENTITY_WRONG_PARAMETERS + * @type Object + * @final + */ + GET_ENTITY_WRONG_PARAMETERS: { + code: 0x305, + httpCode: 400, + module: 'api' + }, + + /** + * Adding entities failed, wrong parameters. + * + * @property ADD_ENTITIES_WRONG_PARAMETERS + * @type Object + * @final + */ + ADD_ENTITIES_WRONG_PARAMETERS: { + code: 0x306, + httpCode: 400, + module: 'api' + }, + + /** + * Updating entity failed, wrong parameters. + * + * @property UPDATE_ENTITY_WRONG_PARAMETERS + * @type Object + * @final + */ + UPDATE_ENTITY_WRONG_PARAMETERS: { + code: 0x307, + httpCode: 400, + module: 'api' } }; diff --git a/lib/controllers/index.js b/lib/controllers/index.js index aaa3e24..7ac8597 100644 --- a/lib/controllers/index.js +++ b/lib/controllers/index.js @@ -1,7 +1,10 @@ 'use strict'; /** - * Base controllers' stuff to be used by all controllers. + * Controllers execute actions on routed messages. + * + * A controller performs controls on incoming parameters, executes the requested action and send a response to the + * client. * * // Load module "controllers" * var controllers = require('@openveo/api').controllers; @@ -11,6 +14,7 @@ */ module.exports.Controller = process.requireApi('lib/controllers/Controller.js'); +module.exports.HttpController = process.requireApi('lib/controllers/HttpController.js'); module.exports.EntityController = process.requireApi('lib/controllers/EntityController.js'); module.exports.ContentController = process.requireApi('lib/controllers/ContentController.js'); module.exports.SocketController = process.requireApi('lib/controllers/SocketController.js'); diff --git a/lib/database/Database.js b/lib/database/Database.js deleted file mode 100644 index 1bedb1f..0000000 --- a/lib/database/Database.js +++ /dev/null @@ -1,293 +0,0 @@ -'use strict'; - -/** - * @module database - */ - -/** - * Defines base database for all databases. - * - * Use {{#crossLink "factory"}}{{/crossLink}} to get an instance of a MongoDB Database. - * - * @class Database - * @constructor - * @param {Object} databaseConf A database configuration object depending on the database type - * @param {String} databaseConf.type The database type - * @param {String} databaseConf.host Database server host - * @param {Number} databaseConf.port Database server port - * @param {String} databaseConf.database The name of the database - * @param {String} databaseConf.username The name of the database user - * @param {String} databaseConf.password The password of the database user - * @throws {TypeError} If database configuration is not as expected - */ -function Database(databaseConf) { - if (!databaseConf) - throw new TypeError('Missing database configuration'); - - Object.defineProperties(this, { - - /** - * Database's type. - * - * @property type - * @type String - * @final - */ - type: {value: databaseConf.type}, - - /** - * Database's host. - * - * @property host - * @type String - * @final - */ - host: {value: databaseConf.host}, - - /** - * Database's port. - * - * @property port - * @type Number - * @final - */ - port: {value: databaseConf.port}, - - /** - * Database's name. - * - * @property name - * @type String - * @final - */ - name: {value: databaseConf.database}, - - /** - * Database's user name. - * - * @property username - * @type String - * @final - */ - username: {value: databaseConf.username}, - - /** - * Database's user password. - * - * @property password - * @type String - * @final - */ - password: {value: databaseConf.password} - - }); -} - -module.exports = Database; - -/** - * Establishes connection to the database. - * - * @method connect - * @async - * @param {Function} callback The function to call when connection to the database is established - * - **Error** The error if an error occurred, null otherwise - */ -Database.prototype.connect = function() { - throw new Error('connect method not implemented for this database'); -}; - -/** - * Closes connection to the database. - * - * @method close - * @async - * @param {Function} callback The function to call when connection is closed - * - **Error** The error if an error occurred, null otherwise - */ -Database.prototype.close = function() { - throw new Error('close method not implemented for this database'); -}; - -/** - * Inserts several documents into a collection. - * - * @method insert - * @async - * @param {String} collection The collection to work on - * @param {Array} data Document(s) to insert into the collection - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The total amount of documents inserted - * - **Array** All the documents inserted - */ -Database.prototype.insert = function() { - throw new Error('insert method not implemented for this database'); -}; - -/** - * Removes several documents from a collection. - * - * @method remove - * @async - * @param {String} collection The collection to work on - * @param {Object} filter Filters formatted like MongoDB filters - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of deleted documents - */ -Database.prototype.remove = function() { - throw new Error('remove method not implemented for this database'); -}; - -/** - * Removes a property on all documents in the collection. - * - * @method removeProp - * @async - * @param {String} collection The collection to work on - * @param {String} property The property name to remove - * @param {Object} filter Filters formatted like MongoDB filters - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of updated documents - */ -Database.prototype.removeProp = function() { - throw new Error('removeProp method not implemented for this database'); -}; - -/** - * Updates several documents from collection. - * - * @method update - * @async - * @param {String} collection The collection to work on - * @param {Object} filter Filters formatted like MongoDB filters - * @param {Object} data Document data - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of updated documents - */ -Database.prototype.update = function() { - throw new Error('update method not implemented for this database'); -}; - -/** - * Gets a list of documents. - * - * @method get - * @async - * @param {String} collection The collection to work on - * @param {Object} [criteria] MongoDB criterias - * @param {Object} [projection] MongoDB projection - * @param {Number} [limit] A limit number of items to retrieve (all by default) - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The retrieved documents - */ -Database.prototype.get = function() { - throw new Error('get method not implemented for this database'); -}; - -/** - * Gets an ordered list of documents by page. - * - * @method search - * @async - * @param {String} collection The collection to work on - * @param {Object} [criteria] MongoDB criterias - * @param {Object} [projection] MongoDB projection - * @param {Number} [limit] The maximum number of expected documents - * @param {Number} [page] The expected page - * @param {Object} [sort] A sort object - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The list of documents - * - **Object** Pagination information - */ -Database.prototype.search = function() { - throw new Error('search method not implemented for this database'); -}; - -/** - * Gets the list of indexes for a collection. - * - * @method getIndexes - * @async - * @param {String} collection The collection to work on - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The list of indexes - */ -Database.prototype.getIndexes = function() { - throw new Error('getIndexes method not implemented for this database'); -}; - -/** - * Creates indexes for a collection. - * - * @method createIndexes - * @async - * @param {String} collection The collection to work on - * @param {Array} indexes A list of indexes using MongoDB format - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Object** Information about the operation - */ -Database.prototype.createIndexes = function() { - throw new Error('createIndexes method not implemented for this database'); -}; - -/** - * Gets an express-session store for the database. - * - * @method getStore - * @param {String} collection The collection to work on - * @return {Store} An express-session store - */ -Database.prototype.getStore = function() { - throw new Error('getStore method not implemented for this database'); -}; - -/** - * increase values in several documents from collection. - * - * @method increase - * @async - * @param {String} collection The collection to work on - * @param {Object} filter Filters formatted like MongoDB filters - * @param {Object} data Document data - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of increased documents - */ -Database.prototype.increase = function() { - throw new Error('increase method not implemented for this database'); -}; - -/** - * Renames a collection. - * - * @method renameCollection - * @async - * @param {String} collection The collection to work on - * @param {String} target The new name of the collection - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - */ -Database.prototype.renameCollection = function() { - throw new Error('renameCollection method not implemented for this database'); -}; - -/** - * Remove a collection from the database - * - * @method removeCollection - * @async - * @param {String} collection The collection to work on - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - */ -Database.prototype.removeCollection = function() { - throw new Error('removeCollection method not implemented for this database'); -}; diff --git a/lib/database/factory.js b/lib/database/factory.js deleted file mode 100644 index e7cd662..0000000 --- a/lib/database/factory.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -/** - * @module database - */ - -/** - * Defines a factory to get an instance of a {{#crossLink "Database"}}{{/crossLink}}. - * - * // Example on how to use a MongoDB database - * var openVeoApi = require('@openveo/api'); - * var databaseConf = { - * type: 'mongodb', // Database type - * host: 'localhost', // MongoDB server host - * port: 27017, // MongoDB port - * database: 'DATABASE_NAME', // Replace DATABASE_NAME by the name of the OpenVeo database - * username: 'DATABASE_USER_NAME', // Replace DATABASE_USER_NAME by the name of the database user - * password: 'DATABASE_USER_PWD', // Replace DATABASE_USER_PWD by the password of the database user - * replicaSet: 'REPLICA_SET_NAME', // Replace REPLICA_SET_NAME by the name of the ReplicaSet - * seedlist: 'IP_1:PORT_1,IP_2:PORT_2' // The comma separated list of secondary servers - * }; - * - * // Create a new instance of the database - * var db = openVeoApi.database.factory.get(databaseConf); - * - * @class factory - * @static - */ - -/** - * Gets an instance of a Database client using the given database configuration. - * - * @method get - * @static - * @param {Object} databaseConf A database configuration object - * @param {String} databaseConf.type The database type (only 'mongodb' is supported for now) - * @return {Database} The database - * @throws {TypeError} If database type is unknown - */ -module.exports.get = function(databaseConf) { - if (databaseConf && databaseConf.type) { - switch (databaseConf.type) { - - case 'mongodb': - var MongoDatabase = process.requireApi('lib/database/mongodb/MongoDatabase.js'); - return new MongoDatabase(databaseConf); - - default: - throw new TypeError('Unknown database type'); - } - } else - throw new TypeError('Invalid database configuration'); -}; diff --git a/lib/database/index.js b/lib/database/index.js deleted file mode 100644 index 31a56a1..0000000 --- a/lib/database/index.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -/** - * Databases implementations. - * - * // Load module "database" - * var database = require('@openveo/api').database; - * - * @module database - * @main database - */ - -module.exports.factory = process.requireApi('lib/database/factory.js'); -module.exports.Database = process.requireApi('lib/database/Database.js'); diff --git a/lib/database/mongodb/MongoDatabase.js b/lib/database/mongodb/MongoDatabase.js deleted file mode 100755 index 7bcceab..0000000 --- a/lib/database/mongodb/MongoDatabase.js +++ /dev/null @@ -1,416 +0,0 @@ -'use strict'; - -/** - * @module database - */ - -var util = require('util'); -var mongodb = require('mongodb'); -var session = require('express-session'); -var MongoStore = require('connect-mongo')(session); -var Database = process.requireApi('lib/database/Database.js'); -var utilExt = process.requireApi('lib/util.js'); -var MongoClient = mongodb.MongoClient; - -/** - * Defines a MongoDB Database. - * - * Use {{#crossLink "factory"}}{{/crossLink}} to get an instance of a MongoDB Database. - * - * @class MongoDatabase - * @extends Database - * @constructor - * @param {Object} databaseConf A database configuration object - * @param {String} databaseConf.type The database type ("mongodb") - * @param {String} databaseConf.host MongoDB server host - * @param {Number} databaseConf.port MongoDB server port - * @param {String} databaseConf.database The name of the database - * @param {String} databaseConf.username The name of the database user - * @param {String} databaseConf.password The password of the database user - * @param {String} [databaseConf.replicaSet] The name of the ReplicaSet - * @param {String} [databaseConf.seedlist] The comma separated list of secondary servers - */ -function MongoDatabase(databaseConf) { - MongoDatabase.super_.call(this, databaseConf); - - Object.defineProperties(this, { - - /** - * The name of the replica set. - * - * @property replicaSet - * @type String - * @final - */ - replicaSet: {value: databaseConf.replicaSet}, - - /** - * A comma separated list of secondary servers. - * - * @property seedlist - * @type String - * @final - */ - seedlist: {value: databaseConf.seedlist} - - }); -} - -module.exports = MongoDatabase; -util.inherits(MongoDatabase, Database); - -/** - * Establishes connection to the database. - * - * @method connect - * @async - * @param {Function} callback The function to call when connection to the database is established - * - **Error** The error if an error occurred, null otherwise - */ -MongoDatabase.prototype.connect = function(callback) { - var self = this; - var connectionUrl = 'mongodb://' + this.username + ':' + this.password + '@' + this.host + ':' + this.port; - var database = '/' + this.name; - var seedlist = ',' + this.seedlist; - var replicaset = '?replicaSet=' + this.replicaSet + '&readPreference=secondary'; - - // Connect to a Replica Set or not - if (this.seedlist != undefined && - this.seedlist != '' && - this.replicaSet != undefined && - this.replicaSet != '') { - connectionUrl = connectionUrl + seedlist + database + replicaset; - } else - connectionUrl = connectionUrl + database; - - MongoClient.connect(connectionUrl, function(error, db) { - - // Connection succeeded - if (!error) - self.db = db; - - callback(error); - }); - -}; - -/** - * Closes connection to the database. - * - * @method close - * @async - * @param {Function} callback The function to call when connection is closed - * - **Error** The error if an error occurred, null otherwise - */ -MongoDatabase.prototype.close = function(callback) { - this.db.close(callback); -}; - -/** - * Inserts several documents into a collection. - * - * @method insert - * @async - * @param {String} collection The collection to work on - * @param {Array} data Document(s) to insert into the collection - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The total amount of documents inserted - * - **Array** All the documents inserted - */ -MongoDatabase.prototype.insert = function(collection, data, callback) { - this.db.collection(collection, function(error, fetchedCollection) { - if (error) - return callback(error); - - fetchedCollection.insertMany(data, function(error, result) { - if (error) - callback(error); - else - callback(null, result.insertedCount, result.ops); - }); - }); -}; - -/** - * Removes several documents from a collection. - * - * @method remove - * @async - * @param {String} collection The collection to work on - * @param {Object} filter Filters formatted like MongoDB filters - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of deleted documents - */ -MongoDatabase.prototype.remove = function(collection, filter, callback) { - this.db.collection(collection, function(error, fetchedCollection) { - if (error) - return callback(error); - - fetchedCollection.deleteMany(filter, function(error, result) { - if (error) - callback(error); - else - callback(null, result.deletedCount); - }); - }); -}; - -/** - * Removes a property on all documents in the collection. - * - * @method removeProp - * @async - * @param {String} collection The collection to work on - * @param {String} property The property name to remove - * @param {Object} filter Filters formatted like MongoDB filters - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of updated documents - */ -MongoDatabase.prototype.removeProp = function(collection, property, filter, callback) { - var mongoFilter = {}; - mongoFilter[property] = {$exists: true}; - mongoFilter = utilExt.merge(mongoFilter, filter); - - var update = {}; - update['$unset'] = {}; - update['$unset'][property] = ''; - - this.db.collection(collection, function(error, fetchedCollection) { - if (error) - return callback(error); - - fetchedCollection.updateMany(mongoFilter, update, function(error, result) { - if (error) - callback(error); - else - callback(null, result.modifiedCount); - }); - }); -}; - -/** - * Updates several documents from collection. - * - * @method update - * @async - * @param {String} collection The collection to work on - * @param {Object} filter Filters formatted like MongoDB filters - * @param {Object} data Document data - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of updated documents - */ -MongoDatabase.prototype.update = function(collection, filter, data, callback) { - var update = {$set: data}; - - this.db.collection(collection, function(error, fetchedCollection) { - if (error) - return callback(error); - - fetchedCollection.updateMany(filter, update, function(error, result) { - if (error) - callback(error); - else - callback(null, result.modifiedCount); - }); - }); -}; - -/** - * Gets a list of documents. - * - * @method get - * @async - * @param {String} collection The collection to work on - * @param {Object} [criteria] MongoDB criterias - * @param {Object} [projection] MongoDB projection - * @param {Number} [limit] A limit number of items to retrieve (all by default) - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The retrieved documents - */ -MongoDatabase.prototype.get = function(collection, criteria, projection, limit, callback) { - this.db.collection(collection, function(error, fetchedCollection) { - if (error) - return callback(error); - - criteria = criteria || {}; - projection = projection || {}; - limit = limit || -1; - - if (limit === -1) - fetchedCollection.find(criteria, projection).toArray(callback); - else - fetchedCollection.find(criteria, projection).limit(limit).toArray(callback); - }); -}; - -/** - * Gets an ordered list of documents by page. - * - * @method search - * @async - * @param {String} collection The collection to work on - * @param {Object} [criteria] MongoDB criterias - * @param {Object} [projection] MongoDB projection - * @param {Number} [limit] The maximum number of expected documents - * @param {Number} [page] The expected page - * @param {Object} [sort] A sort object - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The list of documents - * - **Object** Pagination information - */ -MongoDatabase.prototype.search = function(collection, criteria, projection, limit, page, sort, callback) { - this.db.collection(collection, function(error, fetchedCollection) { - if (error) - return callback(error); - - criteria = criteria || {}; - projection = projection || {}; - limit = limit || -1; - sort = sort || {}; - var skip = limit * page || 0; - - if (limit === -1) - fetchedCollection.find(criteria, projection).sort(sort).toArray(callback); - else { - var cursor = fetchedCollection.find(criteria, projection).sort(sort).skip(skip).limit(limit); - cursor.toArray(function(err, res) { - if (err) return callback(err, null, null); - cursor.count(false, null, function(error, count) { - if (error) callback(error, null, null); - var paginate = { - limit: limit, - page: page, - pages: Math.ceil(count / limit), - size: count - }; - var resultArray = res || []; - callback(error, resultArray, paginate); - }); - }); - } - - }); -}; - -/** - * Gets the list of indexes for a collection. - * - * @method getIndexes - * @async - * @param {String} collection The collection to work on - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The list of indexes - */ -MongoDatabase.prototype.getIndexes = function(collection, callback) { - this.db.collection(collection, function(error, fetchedCollection) { - if (error) - return callback(error); - - fetchedCollection.indexes(callback); - }); -}; - -/** - * Creates indexes for a collection. - * - * @method createIndexes - * @async - * @param {String} collection The collection to work on - * @param {Array} indexes A list of indexes using MongoDB format - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Object** Information about the operation - */ -MongoDatabase.prototype.createIndexes = function(collection, indexes, callback) { - this.db.collection(collection, function(error, fetchedCollection) { - if (error) - return callback(error); - - fetchedCollection.createIndexes(indexes, callback); - }); -}; - -/** - * Gets an express-session store for this database. - * - * @method getStore - * @param {String} collection The collection to work on - * @return {Store} An express-session store - */ -MongoDatabase.prototype.getStore = function(collection) { - return new MongoStore({db: this.db, collection: collection}); -}; - -/** - * increase values in several documents from collection. - * - * @method increase - * @async - * @param {String} collection The collection to work on - * @param {Object} filter Filters formatted like MongoDB filters - * @param {Object} data Document data - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of increased documents - */ -MongoDatabase.prototype.increase = function(collection, filter, data, callback) { - var increase = {$inc: data}; - - this.db.collection(collection, function(error, fetchedCollection) { - if (error) - return callback(error); - - fetchedCollection.updateMany(filter, increase, function(error, result) { - if (error) - callback(error); - else - callback(null, result.modifiedCount); - }); - }); -}; - -/** - * Renames a collection. - * - * @method renameCollection - * @async - * @param {String} collection The collection to work on - * @param {String} target The new name of the collection - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - */ -MongoDatabase.prototype.renameCollection = function(collection, target, callback) { - var self = this; - - self.db.listCollections({name: collection}).toArray(function(error, item) { - if (!item.length) - return callback(); - - self.db.collection(collection, function(error, fetchedCollection) { - if (error) - return callback(error); - - fetchedCollection.rename(target, callback); - }); - }); -}; - -/** - * Remove a collection from the database - * - * @method removeCollection - * @async - * @param {String} collection The collection to work on - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - */ -MongoDatabase.prototype.removeCollection = function(collection, callback) { - this.db.dropCollection(collection, callback); -}; diff --git a/lib/errors/StorageError.js b/lib/errors/StorageError.js new file mode 100644 index 0000000..9c521bc --- /dev/null +++ b/lib/errors/StorageError.js @@ -0,0 +1,59 @@ +'use strict'; + +/** + * @module errors + */ + +var util = require('util'); + +/** + * Defines a StorageError to be thrown when a storage error occurred. + * + * var openVeoApi = require('@openveo/api'); + * throw new openVeoApi.errors.StorageError(42); + * + * @class StorageError + * @extends Error + * @constructor + * @param {String} message The error message + * @param {Number} code The code corresponding to the error + */ +function StorageError(message, code) { + Error.captureStackTrace(this, this.constructor); + + Object.defineProperties(this, { + + /** + * The error code. + * + * @property code + * @type Number + * @final + */ + code: {value: code}, + + /** + * Error message. + * + * @property message + * @type String + * @final + */ + message: {value: 'A storage error occurred with code "' + code + '"', writable: true}, + + /** + * The error name. + * + * @property name + * @type String + * @final + */ + name: {value: 'StorageError', writable: true} + + }); + + if (message) this.message = message; +} + +module.exports = StorageError; +util.inherits(StorageError, Error); diff --git a/lib/errors/index.js b/lib/errors/index.js index 9ce12dc..8981bcb 100644 --- a/lib/errors/index.js +++ b/lib/errors/index.js @@ -12,3 +12,4 @@ module.exports.AccessError = process.requireApi('lib/errors/AccessError.js'); module.exports.NotFoundError = process.requireApi('lib/errors/NotFoundError.js'); +module.exports.StorageError = process.requireApi('lib/errors/StorageError.js'); diff --git a/lib/fileSystem.js b/lib/fileSystem.js old mode 100755 new mode 100644 index c83e240..635bb02 --- a/lib/fileSystem.js +++ b/lib/fileSystem.js @@ -597,6 +597,7 @@ module.exports.mkdir = function(directoryPath, callback) { * @method rmdir * @static * @async + * @deprecated Use rm instead * @param {String} directoryPath Path of the directory to remove * @param {Function} [callback] The function to call when done * - **Error** The error if an error occurred, null otherwise @@ -720,3 +721,36 @@ module.exports.getFileTypeFromBuffer = function(file) { return this.FILE_TYPES.UNKNOWN; }; + +/** + * Removes a resource. + * + * If resource is a directory, the whole directory is removed. + * + * @method rm + * @static + * @async + * @param {String} resourcePath Path of the resource to remove + * @param {Function} [callback] The function to call when done + * - **Error** The error if an error occurred, null otherwise + */ +module.exports.rm = function(resourcePath, callback) { + callback = callback || function(error) { + if (error) + process.logger.error('rm error', {error: error}); + else + process.logger.silly(resourcePath + ' resource removed'); + }; + + if (!resourcePath) + return callback(new TypeError('Invalid resource path, expected a string')); + + fs.stat(resourcePath, function(error, stats) { + if (error) return callback(error); + + if (stats.isDirectory()) + rmdirRecursive(resourcePath, callback); + else + fs.unlink(resourcePath, callback); + }); +}; diff --git a/lib/grunt/index.js b/lib/grunt/index.js index 8e40c7c..ab8c524 100644 --- a/lib/grunt/index.js +++ b/lib/grunt/index.js @@ -12,3 +12,4 @@ module.exports.renameTask = process.requireApi('lib/grunt/renameTask.js'); module.exports.removeTask = process.requireApi('lib/grunt/removeTask.js'); +module.exports.ngDpTask = process.requireApi('lib/grunt/ngDpTask/ngDpTask.js'); diff --git a/lib/grunt/ngDpTask/ComponentExpression.js b/lib/grunt/ngDpTask/ComponentExpression.js new file mode 100644 index 0000000..3cbdefd --- /dev/null +++ b/lib/grunt/ngDpTask/ComponentExpression.js @@ -0,0 +1,62 @@ +'use strict'; + +/** + * @module grunt + */ + +var util = require('util'); +var ElementExpression = process.requireApi('lib/grunt/ngDpTask/ElementExpression.js'); + +/** + * A JavaScript component expression as angularJsApp.component(). + * + * @class ComponentExpression + * @constructor + * @param {Object} expression The component call expression as returned by esprima + */ +function ComponentExpression(expression) { + ComponentExpression.super_.call(this, expression); +} + +module.exports = ComponentExpression; +util.inherits(ComponentExpression, ElementExpression); + +/** + * Validates that the expression is an AngularJS component definition expression. + * + * An AngularJS component definition expression must have two arguments: + * - The name of the component to define + * - The description object of the component + * + * @method isValid + * @return {Boolean} true if this is a valid AngularJS component definition expression, false otherwise + */ +ComponentExpression.prototype.isValid = function() { + return (this.expression.arguments[0].type === 'Literal' && + this.expression.arguments.length === 2 && + this.expression.arguments[1].type === 'ObjectExpression' + ); +}; + +/** + * Gets AngularJS component dependencies. + * + * The following dependency expressions are supported: + * - The attribute "controller" of the component definition + * + * @method getDependencies + * @return {Array} The list of dependencies + */ +ComponentExpression.prototype.getDependencies = function() { + var dependencies = []; + + // AngularJS components may use the "require" or "controller" options with dependency injection + if (this.expression.arguments[1].type === 'ObjectExpression') { + this.expression.arguments[1].properties.forEach(function(property) { + if (property.key.name === 'controller' && property.value.type === 'Literal') + dependencies.push(property.value.value); + }); + } + + return dependencies; +}; diff --git a/lib/grunt/ngDpTask/ConfigExpression.js b/lib/grunt/ngDpTask/ConfigExpression.js new file mode 100644 index 0000000..1b5d302 --- /dev/null +++ b/lib/grunt/ngDpTask/ConfigExpression.js @@ -0,0 +1,62 @@ +'use strict'; + +/** + * @module grunt + */ + +var util = require('util'); +var Expression = process.requireApi('lib/grunt/ngDpTask/Expression.js'); + +/** + * An AngularJS JavaScript config expression. + * + * AngularJS config expressions uses angular.module.config(): + * angular.module('AngularJsModule').config(['Dependency1', function(Dependency1) {}); + * + * @class ConfigExpression + * @constructor + * @param {Object} expression The config expression as returned by esprima + */ +function ConfigExpression(expression) { + ConfigExpression.super_.call(this, expression); +} + +module.exports = ConfigExpression; +util.inherits(ConfigExpression, Expression); + +/** + * Gets AngularJS config dependencies. + * + * Only dependencies in strict dependency injection are supported. + * + * @method getDependencies + * @return {Array} The list of dependencies + */ +ConfigExpression.prototype.getDependencies = function() { + var dependencies = []; + + if (this.expression.arguments[0].type === 'ArrayExpression') { + this.expression.arguments[0].elements.forEach(function(dependency) { + if (dependency.type === 'Literal') + dependencies.push(dependency.value); + }); + } + + return dependencies; +}; + +/** + * Validates that the expression is a config expression. + * + * An AngularJS config expression must have one argument: + * - Either an array when using strict dependency injection or just a function + * + * @method isValid + * @return {Boolean} true if this is a valid config expression + */ +ConfigExpression.prototype.isValid = function() { + return (this.expression.arguments.length === 1 && + (this.expression.arguments[0].type === 'ArrayExpression' || + this.expression.arguments[0].type === 'FunctionExpression' || + this.expression.arguments[0].type === 'Identifier')); +}; diff --git a/lib/grunt/ngDpTask/ConstantExpression.js b/lib/grunt/ngDpTask/ConstantExpression.js new file mode 100644 index 0000000..202f6b5 --- /dev/null +++ b/lib/grunt/ngDpTask/ConstantExpression.js @@ -0,0 +1,22 @@ +'use strict'; + +/** + * @module grunt + */ + +var util = require('util'); +var ValueExpression = process.requireApi('lib/grunt/ngDpTask/ValueExpression.js'); + +/** + * A JavaScript constant expression as angularJsApp.constant(). + * + * @class ConstantExpression + * @constructor + * @param {Object} expression The constant call expression as returned by esprima + */ +function ConstantExpression(expression) { + ConstantExpression.super_.call(this, expression); +} + +module.exports = ConstantExpression; +util.inherits(ConstantExpression, ValueExpression); diff --git a/lib/grunt/ngDpTask/DirectiveExpression.js b/lib/grunt/ngDpTask/DirectiveExpression.js new file mode 100644 index 0000000..2b58985 --- /dev/null +++ b/lib/grunt/ngDpTask/DirectiveExpression.js @@ -0,0 +1,45 @@ +'use strict'; + +/** + * @module grunt + */ + +var util = require('util'); +var ElementExpression = process.requireApi('lib/grunt/ngDpTask/ElementExpression.js'); + +/** + * A JavaScript directive expression as angularJsApp.directive(). + * + * @class DirectiveExpression + * @constructor + * @param {Object} expression The directive call expression as returned by esprima + */ +function DirectiveExpression(expression) { + DirectiveExpression.super_.call(this, expression); +} + +module.exports = DirectiveExpression; +util.inherits(DirectiveExpression, ElementExpression); + +/** + * Gets AngularJS directive dependencies. + * + * The following dependency expressions are supported: + * - The attribute "controller" of the directive definition + * + * @method getDependencies + * @return {Array} The list of dependencies + */ +DirectiveExpression.prototype.getDependencies = function() { + var dependencies = []; + + // AngularJS directives may use the "require" or "controller" options with dependency injection + if (this.expression.arguments[1].type === 'FunctionExpression') { + this.expression.arguments[1].body.body[0].argument.properties.forEach(function(property) { + if (property.key.name === 'controller' && property.value.type === 'Literal') + dependencies.push(property.value.value); + }); + } + + return dependencies; +}; diff --git a/lib/grunt/ngDpTask/ElementExpression.js b/lib/grunt/ngDpTask/ElementExpression.js new file mode 100644 index 0000000..ee7fc45 --- /dev/null +++ b/lib/grunt/ngDpTask/ElementExpression.js @@ -0,0 +1,129 @@ +'use strict'; + +/** + * @module grunt + */ + +var util = require('util'); +var Expression = process.requireApi('lib/grunt/ngDpTask/Expression.js'); + +/** + * An AngularJS JavaScript element expression. + * + * See Expression.ELEMENTS for supported AngularJS element expressions. + * AngularJS JavaScript element expressions could be: + * - angular.module('moduleName').component() + * - angular.module('moduleName').directive() + * - angular.module('moduleName').controller() + * - angular.module('moduleName').factory() + * - angular.module('moduleName').service() + * - angular.module('moduleName').constant() + * - angular.module('moduleName').service() + * - angular.module('moduleName').decorator() + * - angular.module('moduleName').filter() + * - angular.module('moduleName', []) + * + * @class ElementExpression + * @constructor + * @param {Object} expression The call expression as returned by esprima + */ +function ElementExpression(expression) { + ElementExpression.super_.call(this, expression); +} + +module.exports = ElementExpression; +util.inherits(ElementExpression, Expression); + +/** + * The list of supported AngularJS call expressions. + * + * @property ELEMENTS + * @type Array + * @final + */ +ElementExpression.ELEMENTS = { + COMPONENT: 'component', + DIRECTIVE: 'directive', + CONTROLLER: 'controller', + FACTORY: 'factory', + SERVICE: 'service', + CONSTANT: 'constant', + DECORATOR: 'decorator', + FILTER: 'filter', + MODULE: 'module', + PROVIDER: 'provider', + VALUE: 'value' +}; + +Object.freeze(ElementExpression.ELEMENTS); + +/** + * Gets the expression type. + * + * @method getElementType + * @return {String} The expression type as defined in Expression.ELEMENTS + */ +ElementExpression.prototype.getElementType = function() { + return this.expression.callee.property.name; +}; + +/** + * Gets the name of the AngularJS element defined by this expression. + * + * @method getName + * @return {String} The name of the AngularJS element + */ +ElementExpression.prototype.getName = function() { + return this.expression.arguments[0].value; +}; + +/** + * Validates that the expression is an AngularJS definition expression. + * + * An AngularJS element definition expression must have two arguments: + * - The name of the element to define + * - A function or an array (when including dependencies) + * + * @method isValid + * @return {Boolean} true if this is a valid AngularJS element expression, false otherwise + */ +ElementExpression.prototype.isValid = function() { + return (this.expression.arguments[0].type === 'Literal' && + this.expression.arguments.length === 2 && + (this.expression.arguments[1].type === 'Identifier' || + this.expression.arguments[1].type === 'ArrayExpression' || + this.expression.arguments[1].type === 'FunctionExpression') + ); +}; + +/** + * Checks if the expression is an AngularJS definition. + * + * @method isDefinition + * @return {Boolean} true + */ +ElementExpression.prototype.isDefinition = function() { + return true; +}; + +/** + * Gets AngularJS element dependencies. + * + * The following dependency expressions are supported: + * - Dependencies injected using AngularJS strict dependency injection syntax + * + * @method getDependencies + * @return {Array} The list of dependencies + */ +ElementExpression.prototype.getDependencies = function() { + var dependencies = []; + + if (this.expression.arguments[1].type === 'ArrayExpression') { + this.expression.arguments[1].elements.forEach(function(dependency) { + if (dependency.type === 'Literal') + dependencies.push(dependency.value); + }); + } + + return dependencies; +}; diff --git a/lib/grunt/ngDpTask/Expression.js b/lib/grunt/ngDpTask/Expression.js new file mode 100644 index 0000000..d7127bf --- /dev/null +++ b/lib/grunt/ngDpTask/Expression.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * @module grunt + */ + +/** + * An AngularJS JavaScript definition expression. + * + * See Expression.ELEMENTS for supported AngularJS elements. + * AngularJS JavaScript definition expressions could be: + * - angular.module('moduleName').component() + * - angular.module('moduleName').directive() + * - angular.module('moduleName').controller() + * - angular.module('moduleName').factory() + * - angular.module('moduleName').service() + * - angular.module('moduleName').constant() + * - angular.module('moduleName').service() + * - angular.module('moduleName').decorator() + * - angular.module('moduleName').filter() + * - angular.module('moduleName', []) + * + * @class Expression + * @constructor + * @param {Object} expression The element call expression as returned by esprima + */ +function Expression(expression) { + Object.defineProperties(this, { + + /** + * The element expression as returned by esprima. + * + * @property expression + * @type Object + * @final + */ + expression: {value: expression} + + }); +} + +module.exports = Expression; + +/** + * Validates that the expression is as expected regarding the expression type. + * + * @method isValid + * @return {Boolean} true, sub classes may override it + */ +Expression.prototype.isValid = function() { + return true; +}; diff --git a/lib/grunt/ngDpTask/FilterExpression.js b/lib/grunt/ngDpTask/FilterExpression.js new file mode 100644 index 0000000..5455494 --- /dev/null +++ b/lib/grunt/ngDpTask/FilterExpression.js @@ -0,0 +1,48 @@ +'use strict'; + +/** + * @module grunt + */ + +var util = require('util'); +var Expression = process.requireApi('lib/grunt/ngDpTask/Expression.js'); + +/** + * An AngularJS JavaScript $filter expression. + * + * Filters can be injected using AngularJS $filter service: + * $filter('filterName'); + * + * @class FilterExpression + * @constructor + * @param {Object} expression The $filter expression as returned by esprima + */ +function FilterExpression(expression) { + FilterExpression.super_.call(this, expression); +} + +module.exports = FilterExpression; +util.inherits(FilterExpression, Expression); + +/** + * Gets AngularJS $filter dependency. + * + * @method getDependency + * @return {String} The name of the injected filter + */ +FilterExpression.prototype.getDependency = function() { + return this.expression.arguments[0].value; +}; + +/** + * Validates that the expression is a valid $filter expression. + * + * An AngularJS $filter expression must have one argument: + * - The name of the filter to inject + * + * @method isValid + * @return {Boolean} true if this is a valid $filter expression + */ +FilterExpression.prototype.isValid = function() { + return (this.expression.arguments.length === 1 && this.expression.arguments[0].type === 'Literal'); +}; diff --git a/lib/grunt/ngDpTask/InjectExpression.js b/lib/grunt/ngDpTask/InjectExpression.js new file mode 100644 index 0000000..eb01dea --- /dev/null +++ b/lib/grunt/ngDpTask/InjectExpression.js @@ -0,0 +1,54 @@ +'use strict'; + +/** + * @module grunt + */ + +var util = require('util'); +var Expression = process.requireApi('lib/grunt/ngDpTask/Expression.js'); + +/** + * An AngularJS JavaScript inject assignement expression. + * + * AngularJS inject assignement expressions uses $inject: + * AngularJsElement.$inject = ['$scope']; + * + * @class InjectExpression + * @constructor + * @param {Object} expression The inject assignement expression as returned by esprima + */ +function InjectExpression(expression) { + InjectExpression.super_.call(this, expression); +} + +module.exports = InjectExpression; +util.inherits(InjectExpression, Expression); + +/** + * Gets inject expression dependencies. + * + * @method getDependencies + * @return {Array} The list of dependencies + */ +InjectExpression.prototype.getDependencies = function() { + var dependencies = []; + + this.expression.right.elements.forEach(function(dependency) { + if (dependency.type === 'Literal') + dependencies.push(dependency.value); + }); + + return dependencies; +}; + +/** + * Validates that the expression is a $inject expression. + * + * An AngularJS $inject expression must have an array as the right assignement token. + * + * @method isValid + * @return {Boolean} true if this is a valid $inject expression + */ +InjectExpression.prototype.isValid = function() { + return (this.expression.right.type === 'ArrayExpression'); +}; diff --git a/lib/grunt/ngDpTask/ModuleExpression.js b/lib/grunt/ngDpTask/ModuleExpression.js new file mode 100644 index 0000000..256165b --- /dev/null +++ b/lib/grunt/ngDpTask/ModuleExpression.js @@ -0,0 +1,71 @@ +'use strict'; + +/** + * @module grunt + */ + +var util = require('util'); +var ElementExpression = process.requireApi('lib/grunt/ngDpTask/ElementExpression.js'); + +/** + * A JavaScript module expression as angularJsApp.module('module'). + * + * @class ModuleExpression + * @constructor + * @param {Object} expression The module call expression as returned by esprima + */ +function ModuleExpression(expression) { + ModuleExpression.super_.call(this, expression); +} + +module.exports = ModuleExpression; +util.inherits(ModuleExpression, ElementExpression); + +/** + * Validates that the expression is an AngularJS module definition expression. + * + * An AngularJS module definition expression must have two arguments: + * - The name of the element to define + * - An array of dependencies + * + * @method isValid + * @return {Boolean} true if this is a valid AngularJS module definition expression, false otherwise + */ +ModuleExpression.prototype.isValid = function() { + return (this.expression.arguments[0].type === 'Literal' && + (this.expression.arguments.length === 1 || + (this.expression.arguments.length === 2 && this.expression.arguments[1].type === 'ArrayExpression')) + ); +}; + +/** + * Gets AngularJS module dependencies. + * + * @method getDependencies + * @return {Array} The list of dependencies + */ +ModuleExpression.prototype.getDependencies = function() { + var dependencies = []; + + if (this.isDefinition()) { + this.expression.arguments[1].elements.forEach(function(dependency) { + if (dependency.type === 'Literal') + dependencies.push(dependency.value); + }); + } else + dependencies.push(this.getName()); + + return dependencies; +}; + +/** + * Checks if the module expression is an AngularJS definition. + * + * angular.module() may be used to retrieve a previously registered module or to define a new one. + * + * @method isDefinition + * @return {Boolean} true if this is a module definition, false otherwise + */ +ModuleExpression.prototype.isDefinition = function() { + return (this.expression.arguments.length === 2); +}; diff --git a/lib/grunt/ngDpTask/RouteExpression.js b/lib/grunt/ngDpTask/RouteExpression.js new file mode 100644 index 0000000..188acc0 --- /dev/null +++ b/lib/grunt/ngDpTask/RouteExpression.js @@ -0,0 +1,100 @@ +'use strict'; + +/** + * @module grunt + */ + +var util = require('util'); +var Expression = process.requireApi('lib/grunt/ngDpTask/Expression.js'); + +/** + * An AngularJS ngRoute JavaScript route expression. + * + * AngularJS route expressions uses when: + *
$routeProvider.when('/path', {
+ *   resolve: {
+ *     definition1: ['Dependency1', function() {}],
+ *     definition2: ['Dependency2', function() {}],
+ *   }
+ * });
+ * + * @class RouteExpression + * @constructor + * @param {Object} expression The route expression as returned by esprima + */ +function RouteExpression(expression) { + RouteExpression.super_.call(this, expression); +} + +module.exports = RouteExpression; +util.inherits(RouteExpression, Expression); + +/** + * Gets AngularJS route expression dependencies. + * + * The following dependency expressions are supported: + * - The attribute "controller" of the route + * - All dependencies injected in "resolve" properties + * + * @method getDependencies + * @return {Array} The list of dependencies + */ +RouteExpression.prototype.getDependencies = function() { + var dependencies = []; + + this.expression.arguments[1].properties.forEach(function(property) { + if (property.key.name === 'resolve' && property.value.type === 'ObjectExpression') { + property.value.properties.forEach(function(resolveProperty) { + if (resolveProperty.value.type === 'ArrayExpression') { + resolveProperty.value.elements.forEach(function(dependency) { + if (dependency.type === 'Literal' && dependencies.indexOf(dependency.value) === -1) + dependencies.push(dependency.value); + }); + } + }); + } else if (property.key.name === 'controller' && property.value.type === 'Literal') { + dependencies.push(property.value.value); + } + }); + + return dependencies; +}; + +/** + * Gets AngularJS route definitions. + * + * The following definition expressions are supported: + * - All "resolve" property keys + * + * @method getDefinitions + * @return {Array} The list of definitions + */ +RouteExpression.prototype.getDefinitions = function() { + var definitions = []; + + this.expression.arguments[1].properties.forEach(function(property) { + if (property.key.name === 'resolve' && property.value.type === 'ObjectExpression') { + property.value.properties.forEach(function(resolveProperty) { + definitions.push(resolveProperty.key.name); + }); + } + }); + + return definitions; +}; + +/** + * Validates that the expression is a route expression. + * + * An AngularJS route definition expression must have two arguments: + * - The path of the route + * - The route description object + * + * @method isValid + * @return {Boolean} true if this is a valid route expression + */ +RouteExpression.prototype.isValid = function() { + return (this.expression.arguments.length === 2 && + this.expression.arguments[0].type === 'Literal' && + this.expression.arguments[1].type === 'ObjectExpression'); +}; diff --git a/lib/grunt/ngDpTask/ValueExpression.js b/lib/grunt/ngDpTask/ValueExpression.js new file mode 100644 index 0000000..2d64085 --- /dev/null +++ b/lib/grunt/ngDpTask/ValueExpression.js @@ -0,0 +1,36 @@ +'use strict'; + +/** + * @module grunt + */ + +var util = require('util'); +var ElementExpression = process.requireApi('lib/grunt/ngDpTask/ElementExpression.js'); + +/** + * A JavaScript value expression as angularJsApp.value(). + * + * @class ValueExpression + * @constructor + * @param {Object} expression The value call expression as returned by esprima + */ +function ValueExpression(expression) { + ValueExpression.super_.call(this, expression); +} + +module.exports = ValueExpression; +util.inherits(ValueExpression, ElementExpression); + +/** + * Validates that the expression is an AngularJS value definition expression. + * + * An AngularJS value definition expression must have two arguments: + * - The name of the value to define + * - Could be anything + * + * @method isValid + * @return {Boolean} true if this is a valid AngularJS value expression, false otherwise + */ +ValueExpression.prototype.isValid = function() { + return (this.expression.arguments[0].type === 'Literal' && this.expression.arguments.length === 2); +}; diff --git a/lib/grunt/ngDpTask/expressionFactory.js b/lib/grunt/ngDpTask/expressionFactory.js new file mode 100644 index 0000000..79629fd --- /dev/null +++ b/lib/grunt/ngDpTask/expressionFactory.js @@ -0,0 +1,58 @@ +'use strict'; + +/** + * @module grunt + */ + +var ElementExpression = process.requireApi('lib/grunt/ngDpTask/ElementExpression.js'); +var ComponentExpression = process.requireApi('lib/grunt/ngDpTask/ComponentExpression.js'); +var DirectiveExpression = process.requireApi('lib/grunt/ngDpTask/DirectiveExpression.js'); +var ModuleExpression = process.requireApi('lib/grunt/ngDpTask/ModuleExpression.js'); +var ValueExpression = process.requireApi('lib/grunt/ngDpTask/ValueExpression.js'); +var ConstantExpression = process.requireApi('lib/grunt/ngDpTask/ConstantExpression.js'); + +/** + * Defines a factory to get an instance of an expression. + * + * @class factory + * @static + */ + +/** + * Gets an instance of an Expression. + * + * @method get + * @static + * @param {String} name The AngularJS element name (see Expression.ELEMENTS) + * @param {Object} expression The definition expression as returned by esprima + * @return {Expression} The definition expression + * @throws {TypeError} If expression type is unknown + */ +module.exports.getElementExpression = function(name, expression) { + if (name && expression) { + switch (name) { + + case ElementExpression.ELEMENTS.COMPONENT: + return new ComponentExpression(expression); + + case ElementExpression.ELEMENTS.MODULE: + return new ModuleExpression(expression); + + case ElementExpression.ELEMENTS.DIRECTIVE: + return new DirectiveExpression(expression); + + case ElementExpression.ELEMENTS.VALUE: + return new ValueExpression(expression); + + case ElementExpression.ELEMENTS.CONSTANT: + return new ConstantExpression(expression); + + default: + if (Object.values(ElementExpression.ELEMENTS).indexOf(name) > -1) + return new ElementExpression(expression); + else + throw new TypeError('Unknown definition expression type "' + name + '"'); + } + } else + throw new TypeError('Invalid expression definition'); +}; diff --git a/lib/grunt/ngDpTask/ngDpTask.js b/lib/grunt/ngDpTask/ngDpTask.js new file mode 100644 index 0000000..c4b865d --- /dev/null +++ b/lib/grunt/ngDpTask/ngDpTask.js @@ -0,0 +1,534 @@ +'use strict'; + +/** + * @module grunt + */ + +var path = require('path'); +var fs = require('fs'); +var async = require('async'); +var esprima = require('esprima'); +var utilApi = process.requireApi('lib/util.js'); +var expressionFactory = process.requireApi('lib/grunt/ngDpTask/expressionFactory.js'); +var ElementExpression = process.requireApi('lib/grunt/ngDpTask/ElementExpression.js'); +var InjectExpression = process.requireApi('lib/grunt/ngDpTask/InjectExpression.js'); +var ConfigExpression = process.requireApi('lib/grunt/ngDpTask/ConfigExpression.js'); +var RouteExpression = process.requireApi('lib/grunt/ngDpTask/RouteExpression.js'); +var FilterExpression = process.requireApi('lib/grunt/ngDpTask/FilterExpression.js'); + +/** + * Finds out AngularJS definitions and dependencies for the given content. + * + * This is recursive. + * + * The following JavaScript expressions are used to identify definitions: + * - angular.module('moduleName', []) + * - angular.module('moduleName').component() + * - angular.module('moduleName').directive() + * - angular.module('moduleName').controller() + * - angular.module('moduleName').factory() + * - angular.module('moduleName').service() + * - angular.module('moduleName').constant() + * - angular.module('moduleName').service() + * - angular.module('moduleName').decorator() + * - angular.module('moduleName').filter() + * - angular.module('moduleName').config() + * - angular.module('moduleName').run() + * + * The following JavaScript expressions are used to identify dependencies: + * - MyAngularJsElement.$inject = ['Dependency1', 'Dependency2']; + * - angular.module('moduleName', ['DependencyModule']) + * + * The following JavaScript expressions are used to identify associated modules: + * - angular.module('moduleName') + * + * @method findDependencies + * @param {Object} expression The JavaScript expression to analyze + * @private + */ +function findDependencies(jsExpression) { + var expression; + var results = { + definitions: [], + dependencies: [], + module: null + }; + if (!jsExpression) return results; + + /** + * Merges results from sub expressions into results for the current expression. + * + * @param {Object} newResults Sub expressions results + * @param {Array} [newResults.definitions] The list of definitions in sub expression + * @param {Array} [newResults.dependencies] The list of dependencies in sub expression + * @param {String} [newResults.module] The name of the module the definitions belong to + */ + function mergeResults(newResults) { + if (newResults.definitions) + results.definitions = utilApi.joinArray(results.definitions, newResults.definitions); + + if (newResults.dependencies) + results.dependencies = utilApi.joinArray(results.dependencies, newResults.dependencies); + + if (newResults.module) + results.module = newResults.module; + } + + if (jsExpression.type === 'CallExpression' && jsExpression.callee.type === 'MemberExpression') { + if (Object.values(ElementExpression.ELEMENTS).indexOf(jsExpression.callee.property.name) > -1) { + expression = expressionFactory.getElementExpression(jsExpression.callee.property.name, jsExpression); + if (expression.isValid()) { + var newResults = { + definitions: expression.isDefinition() ? [expression.getName()] : [], + dependencies: expression.getDependencies() + }; + + if (!expression.isDefinition() && expression.getElementType() === ElementExpression.ELEMENTS.MODULE) + newResults.module = expression.getName(); + + mergeResults(newResults); + } + } else if (jsExpression.callee.property.name === 'config' || jsExpression.callee.property.name === 'run') { + expression = new ConfigExpression(jsExpression); + + if (expression.isValid()) { + mergeResults({ + dependencies: expression.getDependencies() + }); + } + } else if (jsExpression.callee.property.name === 'when') { + expression = new RouteExpression(jsExpression); + + if (expression.isValid()) { + mergeResults({ + definitions: expression.getDefinitions(), + dependencies: expression.getDependencies() + }); + } + } + } + + if (jsExpression.type === 'AssignmentExpression' && + jsExpression.left.property && + jsExpression.left.property.name === '$inject' + ) { + expression = new InjectExpression(jsExpression); + + if (expression.isValid()) { + mergeResults({ + dependencies: expression.getDependencies() + }); + } + } + + if (jsExpression.type === 'CallExpression' && jsExpression.callee.name === '$filter') { + expression = new FilterExpression(jsExpression); + + if (expression.isValid()) { + mergeResults({ + dependencies: [expression.getDependency()] + }); + } + } + + if (Object.prototype.toString.call(jsExpression) === '[object Object]') { + for (var property in jsExpression) + mergeResults(findDependencies(jsExpression[property])); + } else if (Object.prototype.toString.call(jsExpression) === '[object Array]') { + jsExpression.forEach(function(value) { + mergeResults(findDependencies(value)); + }); + } + + return results; +} + +/** + * Fetches a script from a list of scripts. + * + * @method findScript + * @param {Array} scripts The list of scripts + * @param {String} property The script property used to identify the script to fetch + * @param {String} value The expected value of the script property + * @return {Object|Null} The found script or null if not found + * @private + */ +function findScript(scripts, property, value) { + for (var i = 0; i < scripts.length; i++) { + if ((Object.prototype.toString.call(scripts[i][property]) === '[object Array]' && + scripts[i][property].indexOf(value) > -1) || + (scripts[i][property] === value) + ) { + return scripts[i]; + } + } + + return null; +} + +/** + * Browses a flat list of scripts to find a script longest dependency chains. + * + * Each script may have several dependencies, each dependency can also have several dependencies. + * findLongestDependencyChains helps find the longest dependency chain of one of the script. + * As the script may have several longest dependency chain, a list of chains is returned. + * + * A chain is an ordered list of script paths. + * + * This is recursive. + * + * @method findLongestDependencyChains + * @param {Array} scripts The flat list of scripts with for each script: + * - **dependencies** The list of dependency names of the script + * - **definitions** The list of definition names of the script + * - **path** The script path + * @param {Object} [script] The script to analyze (default to the first one of the list of scripts) + * @param {Array} [modulesToIgnore] The list of module names to ignore to avoid circular dependencies + * @return {Array} The longest dependency chains + * @private + */ +function findLongestDependencyChains(scripts, script, modulesToIgnore) { + var chains = []; + + if (!script) script = scripts[0]; + + // Avoid circular dependencies + if (modulesToIgnore && script.module && modulesToIgnore.indexOf(script.module) !== -1) return chains; + + // Get script dependencies + if (script.dependencies && script.dependencies.length) { + var longestChainLength; + + // Find dependency chains of the script + script.dependencies.forEach(function(dependency) { + var definitionScript = findScript(scripts, 'definitions', dependency); + + if (definitionScript) + chains = chains.concat(findLongestDependencyChains(scripts, definitionScript, script.definitions)); + }); + + if (chains.length > 0) { + + // Keep the longest chain(s) + chains.sort(function(chain1, chain2) { + // -1 : chain1 before chain2 + // 0 : nothing change + // 1 : chain1 after chain2 + + if (chain1.length > chain2.length) + return -1; + else if (chain1.length < chain2.length) + return 1; + else return 0; + }); + + longestChainLength = chains[0].length; + + chains = chains.filter(function(chain) { + if (chain.length === longestChainLength) { + chain.push(script.path); + return true; + } + + return false; + }); + + return chains; + } + } + + chains.push([script.path]); + return chains; +} + +/** + * Builds the dependencies tree. + * + * @method buildTree + * @param {Array} scripts The flat list of scripts with for each script: + * - **dependencies** The list of dependency names of the script + * - **definitions** The list of definition names of the script + * - **path** The script path + * @return {Array} The list of scripts with their dependencies + * @private + */ +function buildTree(scripts) { + var chains = []; + var tree = { + children: [] + }; + var currentTreeNode = tree; + + // Get the longest dependency chain for each script with the highest dependency + // as the first element of the chain + scripts.forEach(function(script) { + chains = chains.concat(findLongestDependencyChains(scripts, script)); + }); + + // Sort chains by length with longest chains first + chains.sort(function(chain1, chain2) { + // -1 : chain1 before chain2 + // 0 : nothing change + // 1 : chain1 after chain2 + + if (chain1.length > chain2.length) + return -1; + else if (chain1.length < chain2.length) + return 1; + else return 0; + }); + + // Add each chain to the tree + chains.forEach(function(chain) { + + // Add each element of the chain as a child of its parent + chain.forEach(function(scriptPath) { + var currentScript = findScript(scripts, 'path', scriptPath); + var alreadyExists = false; + + if (!currentTreeNode.children) + currentTreeNode.children = []; + + // Check if current script does not exist in node children + for (var i = 0; i < currentTreeNode.children.length; i++) { + if (currentTreeNode.children[i].path === currentScript.path) { + alreadyExists = true; + break; + } + } + + // Add script to the tree + if (!alreadyExists) + currentTreeNode.children.push(currentScript); + + currentTreeNode = currentScript; + }); + + currentTreeNode = tree; + }); + + return tree; +} + +/** + * Retrieves CSS and JS files from tree of scripts in a flattened order. + * + * This is recursive. + * + * @method getTreeResources + * @param {Object} node The node from where to start + * @param {String} node.path The file path + * @param {Array} node.styles The list of css / scss file paths + * @return {Object} An object with: + * - **childrenCss** The list of children CSS files + * - **childrenJs** The list of children CSS files + * - **subChildrenCss** The list of sub children CSS files + * - **subChildrenJs** The list of sub children JS files + * @private + */ +function getTreeResources(node) { + var resources = {childrenCss: [], childrenJs: [], subChildrenCss: [], subChildrenJs: []}; + + // Add css and js of node children then dedupe + if (node.children) { + node.children.forEach(function(subNode) { + var subResources = getTreeResources(subNode); + resources.childrenJs = utilApi.joinArray(resources.childrenJs, [subNode.path]); + resources.childrenCss = utilApi.joinArray(resources.childrenCss, subNode.styles); + resources.subChildrenCss = utilApi.joinArray(resources.subChildrenCss, subResources.childrenCss); + resources.subChildrenJs = utilApi.joinArray(resources.subChildrenJs, subResources.childrenJs); + resources.subChildrenCss = utilApi.joinArray(resources.subChildrenCss, subResources.subChildrenCss); + resources.subChildrenJs = utilApi.joinArray(resources.subChildrenJs, subResources.subChildrenJs); + }); + } else { + + // Add current node css and js then dedupe + resources.childrenCss = utilApi.joinArray(resources.childrenCss, node.styles); + resources.childrenJs = utilApi.joinArray(resources.childrenJs, [node.path]); + + } + + return resources; +} + +/** + * Retrieves CSS and JS files from tree of scripts in a flattened order. + * + * @method getResources + * @param {Object} tree The tree of resources + * @return {Object} An object with: + * - **css** The list of css files in the right order + * - **js** The list of js files in the right order + * the list of JS files + * @private + */ +function getResources(tree) { + var resources = getTreeResources(tree); + return { + css: utilApi.joinArray(resources.childrenCss, resources.subChildrenCss), + js: utilApi.joinArray(resources.childrenJs, resources.subChildrenJs) + }; +} + +/** + * Defines a grunt task to build the list of sources (css and js) of an AngularJS application. + * + * // Register task + * var openVeoApi = require('@openveo/api'); + * grunt.registerMultiTask('ngDp', openVeoApi.grunt.ngDpTask(grunt)); + * + * // Configure task + * grunt.initConfig({ + * 'ngDp': { + * options: { + * basePath: '/path/to/the/', + * cssPrefix: '../../other/css/path/', + * jsPrefix: '../../other/js/path/' + * }, + * app1: { + * src: '/path/to/the/app1/**\/*.*', + * dest: '/path/to/the/app1/topology.json' + * }, + * app2: { + * src: '/path/to/the/app2**\/*.*', + * dest: '/path/to/the/app2/topology.json' + * } + * } + * }); + * + * // Ouput example (/path/to/the/app1/topology.json) + * { + * js: ['../..other/js/path/app1/file1.js', '../..other/js/path/app1/file2.js', [...]], + * css: ['../..other/css/path/app1/file1.css', '../..other/css/path/app1/file2.css', [...]] + * } + * + * AngularJS applications, which respect components architecture, need to be loaded in the right order as some + * components may depend on other components. This task helps build an array of JavaScript files and css / scss files + * in the right order. + * + * For this to work, each module must be declared in a separated file and a single file should not define AngularJS + * elements belonging to several different modules. + * + * Available options are: + * - basePath: The base path which will be replaced by the cssPrefix or jsPrefix + * - cssPrefix: For CSS / SCSS files, replace the basePath by this prefix + * - jsPrefix: For JS files, replace the basePath by this prefix + * + * @class ngDpTask + * @static + */ +module.exports = function(grunt) { + return function() { + var done = this.async(); + var asyncFunctions = []; + var options = this.options({ + basePath: '', + cssPrefix: '', + jsPrefix: '' + }); + + /** + * Generates a file with the list of JS and CSS files in the right order. + * + * @param {Array} sourceFiles The list of grunt source files + * @param {String} destination The destination file which will contain the list of JS files and CSS files + * @param {Function} callback The function to call when it's done with: + * - **Error** If an error occurred, null otherwise + */ + var generateSourcesFile = function(sourceFiles, destination, callback) { + var readAsyncFunctions = []; + var sources = []; + var scripts = []; + var styles = []; + + sourceFiles.forEach(function(sourceFile) { + readAsyncFunctions.push(function(callback) { + grunt.verbose.writeln('read file ' + sourceFile); + + fs.stat(sourceFile, function(error, fileStat) { + if (fileStat) { + fileStat.path = sourceFile; + sources = sources.concat(fileStat); + } + callback(error); + }); + }); + }); + + // Get stats for all source files + async.parallel(readAsyncFunctions, function(error, results) { + if (error) return callback(error); + var analyzeAsyncFunctions = []; + + // Analyze all source files and distinguish JS and CSS files + sources.forEach(function(source) { + if (source.isFile()) { + var pathDescriptor = path.parse(source.path); + + if (pathDescriptor.ext === '.js') { + + // JavaScript files + analyzeAsyncFunctions.push(function(callback) { + fs.readFile(source.path, function(error, content) { + grunt.verbose.writeln('analyze ' + source.path); + var contentString = content.toString(); + + // Try to find parents of the source + var programExpressions = esprima.parseScript(contentString); + var script = findDependencies(programExpressions); + script.path = source.path.replace(options.basePath, options.jsPrefix); + script.styles = []; + scripts.push(script); + callback(error); + }); + }); + } else if (pathDescriptor.ext === '.css' || + pathDescriptor.ext === '.scss' + ) { + + // CSS / SCSS files + styles.push(source.path.replace(options.basePath, options.cssPrefix)); + + } + } + }); + + async.parallel(analyzeAsyncFunctions, function(error) { + if (error) return callback(error); + + // Associate css files with scripts + styles.forEach(function(style) { + for (var i = 0; i < scripts.length; i++) { + var originalScriptPath = scripts[i].path.replace(options.jsPrefix, ''); + var originalStylePath = style.replace(options.cssPrefix, ''); + if (path.dirname(originalScriptPath) === path.dirname(originalStylePath)) { + scripts[i].styles.push(style); + return; + } + } + }); + + // Create final file + grunt.file.write(destination, JSON.stringify(getResources(buildTree(scripts)))); + grunt.log.oklns('Ordered styles and scripts have been saved in ' + destination); + + callback(); + }); + }); + }; + + // Iterates through src-dest pairs + this.files.forEach(function(srcDestFile) { + asyncFunctions.push(function(callback) { + generateSourcesFile(srcDestFile.src, srcDestFile.dest, callback); + }); + }); + + async.series(asyncFunctions, function(error) { + if (error) + grunt.fail.fatal(error); + + done(); + }); + }; +}; diff --git a/lib/middlewares/imageProcessorMiddleware.js b/lib/middlewares/imageProcessorMiddleware.js new file mode 100644 index 0000000..40fe2a3 --- /dev/null +++ b/lib/middlewares/imageProcessorMiddleware.js @@ -0,0 +1,204 @@ +'use strict'; + +/** + * @module middlewares + */ + +var fs = require('fs'); +var path = require('path'); +var gm = require('gm').subClass({ + imageMagick: true +}); +var util = process.requireApi('lib/util.js'); +var fileSystem = process.requireApi('lib/fileSystem.js'); + +/** + * Generates a thumbnail from the the given image. + * + * @method generateThumbnail + * @private + * @param {String} imagePath The image absolute path + * @param {String} cachePath The image cache path + * @param {Number} [width] The expected image width (in px) + * @param {Number} [height] The expected image width (in px) + * @param {Boolean} [crop] Crop the image if the new ratio differs from original one + * @param {Number} [quality] Expected quality from 0 to 100 (default to 90 with 100 the best) + * @return {Function} callback Function to call when its done with: + * - **Error** An error if something went wrong + */ +function generateThumbnail(imagePath, cachePath, width, height, crop, quality, callback) { + var image = gm(imagePath); + + image.size(function(error, size) { + var ratio = size.width / size.height; + var doCrop = crop; + var cropPosition = {}; + var resizeWidth = width || Math.round(height * ratio); + var resizeHeight = height || Math.round(width / ratio); + + if (doCrop && width && height) { + if (ratio < width / height) { + resizeHeight = Math.round(width / ratio); + cropPosition = {x: 0, y: Math.round((resizeHeight - height) / 2)}; + doCrop = resizeHeight > height; + } else { + resizeWidth = Math.round(height * ratio); + cropPosition = {x: Math.round((resizeWidth - width) / 2), y: 0}; + doCrop = resizeWidth > width; + } + } + + image + .noProfile() + .quality(quality) + .resizeExact(resizeWidth, resizeHeight); + + if (doCrop) + image.crop(width, height, cropPosition.x, cropPosition.y); + + image.write(cachePath, callback); + }); +} + +/** + * Defines an expressJS middleware to process images. + * + * var openVeoApi = require('@openveo/api'); + * expressApp.use('/mount-path', openVeoApi.middlewares.imageProcessorMiddleware( + * '/path/to/the/folder/containing/images' + * '/path/to/the/folder/containing/processed/images/cache' + * [ + * { + * id: 'my-thumb', // Id of the style to apply when requesting an image processing + * width: 200, // Expected width (in px) of the image + * quality: 50 // Expected quality from 0 to 100 (default to 90 with 100 the best) + * } + * ] + * )); + * + * // Then it's possible to apply style "my-thumb" to the image /mount-path/my-image.jpg using + * // parameter "style": /mount-path/my-image.jpg?style=my-thumb + * + * If path corresponds to an image with a parameter "style", the style is applied to the image before returning it to + * the client. Actually only one type of manipulation can be applied to an image: generate a thumbnail. + * If path does not correspond to an image the processor is skipped. + * + * @class imageProcessorMiddleware + * @constructor + * @static + * @param {String} imagesDirectory The path of the directory containing the images to process + * @param {String} cacheDirectory The path of the directory containing already processed images (for cache purpose) + * @param {Array} styles The list of available styles to process images with for each style: + * - [String] **id** Id of the style to apply when requesting an image processing + * - [Number] **[width]** Expected width of the image (in px) (default to 10) + * - [Number] **[quality]** Expected quality from 0 to 100 (default to 90 with 100 the best) + * @param {Object} headers The name / value list of headers to send with the image when responding to the client + * @return {Function} The ExpressJS middleware + * @throws {TypeError} If imagesdirectory or styles is empty + */ +module.exports = function(imagesDirectory, cacheDirectory, styles, headers) { + if (!imagesDirectory) throw new TypeError('Missing imagesDirectory parameter'); + if (!styles || !styles.length) throw new TypeError('Missing styles'); + + headers = headers || {}; + + /** + * Fetches a style by its id from the list of styles. + * + * @param {String} id The id of the style to fetch + * @return {Object} The style description object + */ + function fetchStyle(id) { + for (var i = 0; i < styles.length; i++) + if (styles[i].id === id) return styles[i]; + } + + return function(request, response, next) { + var styleId = request.query && request.query.style; + + // No style or no file name: skip processor + if (!styleId) return next(); + + // File path without query parameters and absolute system file path + var filePath = decodeURI(request.url.replace(/\?.*/, '')); + var fileSystemPath = path.join(imagesDirectory, filePath); + var isImage; + + /** + * Sends an image to client as response. + * + * @param {String} imagePath The absolute image path + */ + function sendFile(imagePath) { + response.set(headers); + response.download(imagePath, request.query.filename); + } + + // Get information about the requested file + fs.stat(fileSystemPath, function(error, stats) { + + // Error or not a file: skip image processing + if (error || !stats.isFile()) return next(); + + // Verify that file is an image + var readable = fs.createReadStream(path.normalize(fileSystemPath), {start: 0, end: 300}); + readable.on('data', function(imageChunk) { + try { + util.shallowValidateObject({ + file: imageChunk + }, { + file: {type: 'file', required: true, in: [ + fileSystem.FILE_TYPES.JPG, + fileSystem.FILE_TYPES.PNG, + fileSystem.FILE_TYPES.GIF + ]} + }); + isImage = true; + } catch (e) { + isImage = false; + } + }); + + readable.on('end', function() { + + // File is not an image: skip image processing + if (!isImage) return next(); + + var imageCachePath = path.join(cacheDirectory, styleId, filePath); + + // Find out if file has already been processed + fs.stat(imageCachePath, function(error, stats) { + + // File was found in cache + if (stats && stats.isFile()) + return sendFile(imageCachePath); + + // Apply style (only thumb is available right now) + var style = fetchStyle(styleId); + + if (!style) return next(); + if (!style.width && !style.height) return next(); + + // Create cache directory if it does not exist + fileSystem.mkdir(path.dirname(imageCachePath), function(error) { + if (error) return next(error); + + generateThumbnail(fileSystemPath, + imageCachePath, + style.width, + style.height, + style.crop, + style.quality, + function(error) { + if (error) return next(error); + + sendFile(imageCachePath); + }); + + }); + + }); + }); + }); + }; +}; diff --git a/lib/middlewares/index.js b/lib/middlewares/index.js index 89e3602..ccb0975 100644 --- a/lib/middlewares/index.js +++ b/lib/middlewares/index.js @@ -12,3 +12,4 @@ module.exports.disableCacheMiddleware = process.requireApi('lib/middlewares/disableCacheMiddleware.js'); module.exports.logRequestMiddleware = process.requireApi('lib/middlewares/logRequestMiddleware.js'); +module.exports.imageProcessorMiddleware = process.requireApi('lib/middlewares/imageProcessorMiddleware.js'); diff --git a/lib/models/ContentModel.js b/lib/models/ContentModel.js deleted file mode 100755 index b9b6b35..0000000 --- a/lib/models/ContentModel.js +++ /dev/null @@ -1,455 +0,0 @@ -'use strict'; - -/** - * @module models - */ - -var util = require('util'); -var shortid = require('shortid'); -var EntityModel = process.requireApi('lib/models/EntityModel.js'); -var EntityProvider = process.requireApi('lib/providers/EntityProvider.js'); -var utilExt = process.requireApi('lib/util.js'); -var AccessError = process.requireApi('lib/errors/AccessError.js'); - -/** - * Gets the list of groups from a user. - * - * @example - * - * // Example of user permissions - * ['get-group-Jekrn20Rl', 'update-group-Jekrn20Rl', 'delete-group-YldO3Jie3', 'some-other-permission'] - * - * // Example of returned groups - * { - * 'Jekrn20Rl': ['get', 'update'], // User only has get / update permissions on group 'Jekrn20Rl' - * 'YldO3Jie3': ['delete'], // User only has delete permission on group 'YldO3Jie3' - * ... - * } - * - * @method getUserGroups - * @private - * @param {Object} user The user to extract groups from - * @return {Object} Groups organized by ids - */ -function getUserGroups(user) { - var groups = {}; - - if (user && user.permissions) { - user.permissions.forEach(function(permission) { - var reg = new RegExp('^(get|update|delete)-group-(.+)$'); - var permissionChunks = reg.exec(permission); - if (permissionChunks) { - var operation = permissionChunks[1]; - var groupId = permissionChunks[2]; - - if (!groups[groupId]) - groups[groupId] = []; - - groups[groupId].push(operation); - } - }); - } - - return groups; -} - -/** - * Gets the list of groups of a user, with authorization on a certain operation. - * - * All user groups with authorization on the operation are returned. - * - * @method getUserAuthorizedGroups - * @private - * @param {String} operation The operation (get, update or delete) - * @return {Array} The list of user group ids with authorization on the operation - */ -function getUserAuthorizedGroups(operation) { - var groups = []; - - for (var groupId in this.groups) { - if (this.groups[groupId].indexOf(operation) >= 0) - groups.push(groupId); - } - - return groups; -} - -/** - * Defines a base model for all models which need to manipulate content entities. - * - * A content entity associates a user to the entity and adds controls for CRUD operations - * depending on user's permissions. - * - * // Implement a ContentModel : "CustomContentModel" - * var util = require('util'); - * var openVeoApi = require('@openveo/api'); - * - * function CustomContentModel(user, provider) { - * CustomContentModel.super_.call(this, user, provider); - * } - * - * util.inherits(CustomContentModel, openVeoApi.models.ContentModel); - * - * CustomContentModel.prototype.getSuperAdminId = function() { - * return ADMIN_ID; - * }; - * - * CustomContentModel.prototype.getAnonymousId = function() { - * return ANONYMOUS_ID; - * }; - * - * @class ContentModel - * @extends EntityModel - * @constructor - * @param {Object} [user] The user that will manipulate the entities - * @param {String} [user.id] The user id - * @param {Array} [user.permissions] The list of user's permissions - * @param {Array} [user.groups] The list of user's groups - * @param {EntityProvider} provider The entity provider - */ -function ContentModel(user, provider) { - if (!(provider instanceof EntityProvider)) - throw new Error('A ContentModel needs an EntityProvider'); - - ContentModel.super_.call(this, provider); - - Object.defineProperties(this, { - - /** - * Information about a user. - * - * @property user - * @type Object - * @final - */ - user: {value: user}, - - /** - * User's groups extracted from user's information. - * - * @property groups - * @type Object - * @final - */ - groups: {value: getUserGroups(user)} - - }); -} - -module.exports = ContentModel; -util.inherits(ContentModel, EntityModel); - -// Operations on entities - -/** - * Read operation id. - * - * @property READ_OPERATION - * @type String - * @final - * @static - */ -ContentModel.READ_OPERATION = 'get'; - -/** - * Update operation id. - * - * @property UPDATE_OPERATION - * @type String - * @final - * @static - */ -ContentModel.UPDATE_OPERATION = 'update'; - -/** - * Delete operation id. - * - * @property DELETE_OPERATION - * @type String - * @final - * @static - */ -ContentModel.DELETE_OPERATION = 'delete'; - -/** - * Gets the id of the super administrator. - * - * @method getSuperAdminId - * @return {String} The id of the super admin - * @throw {Error} getSuperAdminId is not implemented - */ -ContentModel.prototype.getSuperAdminId = function() { - throw new Error('getSuperAdminId is not implemented for this ContentModel'); -}; - -/** - * Gets the id of the anonymous user. - * - * @method getAnonymousId - * @return {String} The id of the anonymous user - * @throw {Error} getAnonymousId is not implemented - */ -ContentModel.prototype.getAnonymousId = function() { - throw new Error('getAnonymousId is not implemented for this ContentModel'); -}; - -/** - * Tests if user is the administrator. - * - * @method isUserAdmin - * @param {Object} user The user to test - * @param {String} user.id The user's id - * @return {Boolean} true if the user is the administrator, false otherwise - */ -ContentModel.prototype.isUserAdmin = function(user) { - return user && user.id === this.getSuperAdminId(); -}; - -/** - * Tests if user is the anonymous user. - * - * @method isUserAnonymous - * @param {Object} user The user to test - * @param {String} user.id The user's id - * @return {Boolean} true if the user is the anonymous, false otherwise - */ -ContentModel.prototype.isUserAnonymous = function(user) { - return user && user.id === this.getAnonymousId(); -}; - -/** - * Tests if user is the owner of a content entity. - * - * @method isUserOwner - * @param {Object} entity The entity to test - * @param {Object} entity.metadata Entity information about associated user and groups - * @param {String} entity.metadata.id The id of the user the entity belongs to - * @param {Object} user The user to test - * @param {String} user.id The user's id - * @return {Boolean} true if the user is the owner, false otherwise - */ -ContentModel.prototype.isUserOwner = function(entity, user) { - return user && entity.metadata && entity.metadata.user === user.id; -}; - -/** - * Validates that the user is authorized to manipulate a content entity. - * - * User is authorized to manipulate the entity if one of the following conditions is met : - * - No user is associated to the model - * - The entity belongs to the anonymous user - * - User is the super administrator - * - User is the owner of the entity - * - Entity has associated groups and user has permission to perform the operation on the group - * - * @method isUserAuthorized - * @param {Object} entity The entity to manipulate - * @param {String} operation The operation to perform on the entity - * @return {Boolean} true if the user can manipulate the entity, false otherwise - */ -ContentModel.prototype.isUserAuthorized = function(entity, operation) { - if (this.isUserAdmin(this.user) || - this.isUserOwner(entity, this.user) || - !this.user || - (entity.metadata && this.isUserAnonymous({id: entity.metadata.user})) - ) { - return true; - } - - if (entity.metadata && entity.metadata.groups) { - var userGroups = getUserAuthorizedGroups.call(this, operation); - return utilExt.intersectArray(entity.metadata.groups, userGroups).length; - } - - return false; -}; - -/** - * Gets a single content entity by its id. - * - * If the user has not the necessary permissions, an error will be returned. - * - * @method getOne - * @async - * @param {String} id The entity id - * @param {Object} filter A MongoDB filter - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Object** The entity - */ -ContentModel.prototype.getOne = function(id, filter, callback) { - var self = this; - this.provider.getOne(id, filter, function(error, entity) { - if (!error && !self.isUserAuthorized(entity, ContentModel.READ_OPERATION)) { - callback(new AccessError('User "' + self.user.id + '" doesn\'t have access to entity "' + id + '"')); - } else - callback(error, entity); - }); -}; - -/** - * Gets all content entities. - * - * Only entities that the user can manipulate are returned. - * - * @method get - * @async - * @param {Object} filter A MongoDB filter - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The list of entities - */ -ContentModel.prototype.get = function(filter, callback) { - this.provider.get(this.addAccessFilter(filter), callback); -}; - -/** - * Gets an ordered list of entities by page. - * - * Only entities that the user can manipulate are returned. - * - * @method getPaginatedFilteredEntities - * @async - * @param {Object} [filter] MongoDB filter - * @param {Number} [limit] The maximum number of expected entities - * @param {Number} [page] The expected page - * @param {Object} [sort] A sort object - * @param {Boolean} [populate] true to automatically populate results with additional information - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The list of entities - * - **Object** Pagination information - */ -ContentModel.prototype.getPaginatedFilteredEntities = function(filter, count, page, sort, populate, callback) { - this.provider.getPaginatedFilteredEntities(this.addAccessFilter(filter), count, page, sort, callback); -}; - -/** - * Adds a new content entity. - * - * Information about the user (which becomes the owner) is added to the entity before recording. - * - * @method add - * @async - * @param {Object} data Entity data to store into the collection, its structure depends on the type of entity - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The total amount of items inserted - * - **Object** The added entity - */ -ContentModel.prototype.add = function(data, callback) { - data.metadata = { - user: (this.user && this.user.id) || this.getAnonymousId(), - groups: data.groups || [] - }; - data.id = shortid.generate(); - this.provider.add(data, function(error, insertCount, documents) { - if (callback) - callback(error, insertCount, documents[0]); - }); -}; - -/** - * Updates an entity. - * - * User must have permission to update the entity. - * - * @method update - * @async - * @param {String} id The id of the entity to update - * @param {Object} data Entity data, its structure depends on the type of entity - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of updated items - */ -ContentModel.prototype.update = function(id, data, callback) { - var self = this; - var info = {}; - - if (data.groups) { - info['metadata.groups'] = data.groups.filter(function(group) { - return group ? true : false; - }); - } - if (data.user) - info['metadata.user'] = data.user; - - this.provider.getOne(id, null, function(error, entity) { - if (!error) { - if (self.isUserAuthorized(entity, ContentModel.UPDATE_OPERATION)) { - - // user is authorized to update but he must be owner to update owner - if ((!self.isUserOwner(entity, self.user) && !self.isUserAdmin(self.user))) - delete info['metadata.user']; - - self.provider.update(id, info, callback); - } else - callback(new AccessError('User "' + self.user.id + '" can\'t update entity "' + id + '"')); - } else - callback(error); - }); -}; - -/** - * Removes one or several entities. - * - * User must have permission to remove the entity. - * - * @method remove - * @async - * @param {String|Array} ids Id(s) of the document(s) to remove from the collection - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of deleted entities - */ -ContentModel.prototype.remove = function(ids, callback) { - var self = this; - this.provider.get({id: {$in: ids}}, function(error, entities) { - if (!error) { - var idsToRemove = []; - for (var i = 0; i < entities.length; i++) { - if (self.isUserAuthorized(entities[i], ContentModel.DELETE_OPERATION)) - idsToRemove.push(entities[i].id); - } - - self.provider.remove(idsToRemove, callback); - - } else - callback(error); - }); -}; - -/** - * Adds access rule to the given filter reference. - * - * @method addAccessFilter - * @param {Object} filter The filter to add the access rule to - * @return {Object} The filter - */ -ContentModel.prototype.addAccessFilter = function(filter) { - if (!this.isUserAdmin(this.user) && this.user) { - var userGroups = getUserAuthorizedGroups.call(this, ContentModel.READ_OPERATION); - - if (!filter) - filter = {}; - - if (!filter.$or) - filter.$or = []; - - filter.$or.push({ - 'metadata.user': { - $in: [this.user.id, this.getAnonymousId()] - } - }); - - if (userGroups.length) { - filter.$or.push({ - 'metadata.groups': { - $in: userGroups - } - }); - } - } - - return filter; -}; diff --git a/lib/models/EntityModel.js b/lib/models/EntityModel.js deleted file mode 100755 index 0041758..0000000 --- a/lib/models/EntityModel.js +++ /dev/null @@ -1,156 +0,0 @@ -'use strict'; - -/** - * @module models - */ - -var util = require('util'); -var shortid = require('shortid'); -var EntityProvider = process.requireApi('lib/providers/EntityProvider.js'); -var Model = process.requireApi('lib/models/Model.js'); - -/** - * Defines a base model for all models which need to provide basic - * CRUD (**C**reate **R**ead **U**pdate **D**elete) operations on entities. - * - * Each entity as it's own associated Model (extending EntityModel). - * - * // Implement EntityModel : "CustomEntityModel" - * var util = require('util'); - * var openVeoApi = require('@openveo/api'); - * - * function CustomEntityModel(provider) { - * CustomEntityModel.super_.call(this, provider); - * } - * - * util.inherits(CustomEntityModel, openVeoApi.models.EntityModel); - * - * @class EntityModel - * @extends Model - * @constructor - * @param {EntityProvider} provider The entity provider - * @throws {TypeError} If provider is not an {{#crossLink "EntityProvider"}}{{/crossLink}} - */ -function EntityModel(provider) { - Object.defineProperties(this, { - - /** - * Provider associated to the model. - * - * @property provider - * @type EntityProvider - * @final - */ - provider: {value: provider} - - }); - - if (!(this.provider instanceof EntityProvider)) - throw new TypeError('An EntityModel needs an EntityProvider'); - - EntityModel.super_.call(this, provider); -} - -module.exports = EntityModel; -util.inherits(EntityModel, Model); - -/** - * Gets a single entity by its id. - * - * @method getOne - * @async - * @param {String} id The entity id - * @param {Object} filter A MongoDB filter - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Object** The entity - */ -EntityModel.prototype.getOne = function(id, filter, callback) { - this.provider.getOne(id, filter, callback); -}; - -/** - * Gets all entities. - * - * @method get - * @async - * @param {Object} filter A MongoDB filter - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The list of entities - */ -EntityModel.prototype.get = function(filter, callback) { - this.provider.get(filter, callback); -}; - -/** - * Gets an ordered list of entities by page. - * - * @method getPaginatedFilteredEntities - * @async - * @param {Object} [filter] MongoDB filter - * @param {Number} [limit] The maximum number of expected entities - * @param {Number} [page] The expected page - * @param {Object} [sort] A sort object - * @param {Boolean} [populate] true to automatically populate results with additional information - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The list of entities - * - **Object** Pagination information - */ -EntityModel.prototype.getPaginatedFilteredEntities = function(filter, count, page, sort, populate, callback) { - // TODO change filter format to not directly do a DB call - this.provider.getPaginatedFilteredEntities(filter, count, page, sort, callback); -}; - -/** - * Adds a new entity. - * - * @method add - * @async - * @param {Object} data Entity data to store into the collection, its structure depends on the type of entity - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The total amount of items inserted - * - **Object** The added entity - */ -EntityModel.prototype.add = function(data, callback) { - data.id = data.id || shortid.generate(); - this.provider.add(data, function(error, insertCount, documents) { - if (callback) { - if (error) - callback(error); - else - callback(null, insertCount, documents[0]); - } - }); -}; - -/** - * Updates an entity. - * - * @method update - * @async - * @param {String} id The id of the entity to update - * @param {Object} data Entity data, its structure depends on the type of entity - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of updated items - */ -EntityModel.prototype.update = function(id, data, callback) { - this.provider.update(id, data, callback); -}; - -/** - * Removes one or several entities. - * - * @method remove - * @async - * @param {String|Array} ids Id(s) of the document(s) to remove from the collection - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of deleted entities - */ -EntityModel.prototype.remove = function(ids, callback) { - this.provider.remove(ids, callback); -}; diff --git a/lib/models/Model.js b/lib/models/Model.js deleted file mode 100644 index fabf32e..0000000 --- a/lib/models/Model.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; - -/** - * @module models - */ - -/** - * Base model for all models. - * - * // Implement a Model : "CustomModel" - * var util = require('util'); - * var openVeoApi = require('@openveo/api'); - * - * function CustomModel(provider) { - * CustomModel.super_.call(this, provider); - * } - * - * util.inherits(CustomModel, openVeoApi.models.Model); - * - * @class Model - * @constructor - */ -function Model() {} - -module.exports = Model; diff --git a/lib/models/index.js b/lib/models/index.js deleted file mode 100644 index 5b897e1..0000000 --- a/lib/models/index.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -/** - * Base models to be used by all models. - * - * // Load module "models" - * var models = require('@openveo/api').models; - * - * @module models - * @main models - */ - -module.exports.ContentModel = process.requireApi('lib/models/ContentModel.js'); -module.exports.EntityModel = process.requireApi('lib/models/EntityModel.js'); -module.exports.Model = process.requireApi('lib/models/Model.js'); diff --git a/lib/multipart/MultipartParser.js b/lib/multipart/MultipartParser.js index 6e77c47..069546f 100644 --- a/lib/multipart/MultipartParser.js +++ b/lib/multipart/MultipartParser.js @@ -7,6 +7,7 @@ var fs = require('fs'); var path = require('path'); var multer = require('multer'); +var async = require('async'); var fileSystem = process.requireApi('lib/fileSystem.js'); /** @@ -87,7 +88,16 @@ function MultipartParser(request, fileFields, limits) { * @type Object * @final */ - limits: {value: limits} + limits: {value: limits}, + + /** + * Final paths of files detected in multipart body. + * + * @property detectedFilesPaths + * @type Array + * @final + */ + detectedFilesPaths: {value: []} }); @@ -167,6 +177,21 @@ MultipartParser.prototype.getFileName = function(originalFileName, fieldName, ca MultipartParser.prototype.parse = function(callback) { var self = this; + // Multer does not remove partially parsed files when client aborts the request + // Remove files being written to the disk when client aborts the request + this.request.on('aborted', function() { + var asyncFunctions = []; + self.detectedFilesPaths.forEach(function(detectedFilePath) { + asyncFunctions.push(function(callback) { + fileSystem.rm(detectedFilePath, callback); + }); + }); + + async.parallel(asyncFunctions, function() { + callback(new Error('Parsing aborted by client. Temporary files have been removed')); + }); + }); + multer({ storage: multer.diskStorage({ destination: function(request, file, callback) { @@ -179,7 +204,13 @@ MultipartParser.prototype.parse = function(callback) { } else callback(new Error('No destination path found for field ' + file.fieldname)); }, filename: function(request, file, callback) { - self.getFileName(file.originalname, file.fieldname, callback); + var destinationPath = self.getFileDestination(file.fieldname); + + self.getFileName(file.originalname, file.fieldname, function(error, fileName) { + if (error) return callback(error); + self.detectedFilesPaths.push(path.join(destinationPath, fileName)); + callback(null, fileName); + }); } }), limits: this.limits diff --git a/lib/providers/EntityProvider.js b/lib/providers/EntityProvider.js old mode 100755 new mode 100644 index 0c850de..c78a78a --- a/lib/providers/EntityProvider.js +++ b/lib/providers/EntityProvider.js @@ -8,95 +8,141 @@ var util = require('util'); var Provider = process.requireApi('lib/providers/Provider.js'); /** - * Defines base provider for all providers which need to provide - * basic CRUD (**C**read **R**ead **U**pdate **D**elete) operations on a collection. + * Defines a provider holding a single type of resources. * - * Each entity model as it's own associated Provider (extending EntityProvider). - * - * // Implement a EntityProvider : "CustomEntityProvider" - * var util = require('util'); - * var openVeoApi = require('@openveo/api'); - * - * function CustomEntityProvider(database) { - * CustomEntityProvider.super_.call(this, database, 'customCollection'); - * } - * - * util.inherits(CustomEntityProvider, openVeoApi.providers.EntityProvider); + * An entity provider manages a single type of resources. These resources are stored into the given storage / location. * * @class EntityProvider * @extends Provider * @constructor - * @param {Database} database The database to interact with - * @param {String} collection The name of the collection to work on - * @throws {Error} If database and / or collection are not as expected + * @param {Storage} storage The storage to use to store provider entities + * @param {String} location The location of the entities in the storage + * @throws {TypeError} If storage and / or location is not valid */ -function EntityProvider(database, collection) { - EntityProvider.super_.call(this, database, collection); +function EntityProvider(storage, location) { + EntityProvider.super_.call(this, storage); + + Object.defineProperties(this, { + + /** + * The location of the entities in the storage. + * + * @property location + * @type String + * @final + */ + location: {value: location} + + }); + + if (Object.prototype.toString.call(this.location) !== '[object String]') + throw new TypeError('location must be a string'); } module.exports = EntityProvider; util.inherits(EntityProvider, Provider); /** - * Gets an entity. + * Fetches an entity. + * + * If filter corresponds to more than one entity, the first found entity will be the returned one. * * @method getOne * @async - * @param {String} id The entity id - * @param {Object} filter A MongoDB filter + * @param {ResourceFilter} [filter] Rules to filter entities + * @param {Object} [fields] Fields to be included or excluded from the response, by default all + * fields are returned. Only "exclude" or "include" can be specified, not both + * @param {Array} [fields.include] The list of fields to include in the response, all other fields are excluded + * @param {Array} [fields.exclude] The list of fields to exclude from response, all other fields are included. Ignored + * if include is also specified. * @param {Function} callback The function to call when it's done * - **Error** The error if an error occurred, null otherwise * - **Object** The entity */ -EntityProvider.prototype.getOne = function(id, filter, callback) { - if (!filter) filter = {}; - filter.id = id; - - this.database.get(this.collection, filter, - { - _id: 0 - }, - 1, function(error, entities) { - if (entities && entities.length) - callback(error, entities[0]); - else - callback(error); - }); +EntityProvider.prototype.getOne = function(filter, fields, callback) { + this.storage.getOne(this.location, filter, fields, callback); }; /** - * Gets an ordered list of entities by page. + * Fetches entities. * - * @method getPaginatedFilteredEntities + * @method get * @async - * @param {Object} [filter] MongoDB filter - * @param {Number} [limit] The maximum number of expected entities - * @param {Number} [page] The expected page - * @param {Object} [sort] A sort object + * @param {ResourceFilter} [filter] Rules to filter entities + * @param {Object} [fields] Fields to be included or excluded from the response, by default all + * fields are returned. Only "exclude" or "include" can be specified, not both + * @param {Array} [fields.include] The list of fields to include in the response, all other fields are excluded + * @param {Array} [fields.exclude] The list of fields to exclude from response, all other fields are included. Ignored + * if include is also specified. + * @param {Number} [limit] A limit number of entities to retrieve (10 by default) + * @param {Number} [page] The page number started at 0 for the first page + * @param {Object} [sort] The list of fields to sort by with the field name as key and the sort order as + * value (e.g. {field1: 'asc', field2: 'desc'}) * @param {Function} callback The function to call when it's done * - **Error** The error if an error occurred, null otherwise - * - **Array** The list of entities + * - **Array** The list of retrieved entities * - **Object** Pagination information + * - **Number** limit The specified limit + * - **Number** page The actual page + * - **Number** pages The total number of pages + * - **Number** size The total number of entities */ -EntityProvider.prototype.getPaginatedFilteredEntities = function(filter, count, page, sort, callback) { - this.database.search(this.collection, filter, null, count, page, sort, callback); +EntityProvider.prototype.get = function(filter, fields, limit, page, sort, callback) { + this.storage.get(this.location, filter, fields, limit, page, sort, callback); }; /** - * Gets all entities. + * Gets all entities from storage iterating on all pages. * - * @method get + * @method getAll * @async - * @param {Object} filter A MongoDB filter - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Array** The list of entities + * @param {ResourceFilter} [filter] Rules to filter entities + * @param {Object} [fields] Fields to be included or excluded from the response, by default all + * fields are returned. Only "exclude" or "include" can be specified, not both + * @param {Array} [fields.include] The list of fields to include in the response, all other fields are excluded + * @param {Array} [fields.exclude] The list of fields to exclude from response, all other fields are included. Ignored + * if include is also specified. + * @param {Object} sort The list of fields to sort by with the field name as key and the sort order as + * value (e.g. {field1: 'asc', field2: 'desc'}) + * @param {Function} callback Function to call when it's done with: + * - **Error** An error if something went wrong, null otherwise + * - **Array** The list of entities */ -EntityProvider.prototype.get = function(filter, callback) { - this.database.get(this.collection, filter, { - _id: 0 - }, - -1, callback); +EntityProvider.prototype.getAll = function(filter, fields, sort, callback) { + var self = this; + var page = 0; + var allEntities = []; + + /** + * Fetches all entities iterating on all pages. + * + * @param {Function} callback The function to call when it's done + */ + function getEntities(callback) { + self.get(filter, fields, null, page, sort, function(error, entities, pagination) { + if (error) return callback(error); + + allEntities = allEntities.concat(entities); + + if (page < pagination.pages - 1) { + + // There are other pages + // Get next page + page++; + getEntities(callback); + + } else { + + // No more pages + // End it + callback(null); + } + }); + } + + getEntities(function(error) { + callback(error, allEntities); + }); }; /** @@ -104,115 +150,66 @@ EntityProvider.prototype.get = function(filter, callback) { * * @method add * @async - * @param {Array} data The list of entities' data to store into the collection + * @param {Array} entities The list of entities to store * @param {Function} [callback] The function to call when it's done * - **Error** The error if an error occurred, null otherwise - * - **Number** The total amount of documents inserted - * - **Array** All the documents inserted + * - **Number** The total amount of entities inserted + * - **Array** The list of added entities */ -EntityProvider.prototype.add = function(data, callback) { - var datas = Array.isArray(data) ? data : [data]; +EntityProvider.prototype.add = function(entities, callback) { + if (!entities || !entities.length) return callback(null, 0); - this.database.insert(this.collection, datas, callback || function(error) { - if (error) - process.logger.error('Error while inserting entities with message : ' + - error.message, datas); - }); + this.storage.add(this.location, entities, function(error, total, addedEntities) { + this.executeCallback(callback, error, total, addedEntities); + }.bind(this)); }; /** * Updates an entity. * - * If the entity has the property "locked", it won't be updated. - * - * @method update + * @method updateOne * @async - * @param {String} id The id of the entity to update - * @param {Object} data Entity data, its structure depends on the entity type - * @param {Function} callback The function to call when it's done + * @param {ResourceFilter} [filter] Rules to filter the entity to update + * @param {Object} data The modifications to perform + * @param {Function} [callback] The function to call when it's done * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of updated items + * - **Number** 1 if everything went fine */ -EntityProvider.prototype.update = function(id, data, callback) { - var filter = {}; - filter['locked'] = {$ne: true}; - filter['id'] = id; - - this.database.update(this.collection, filter, data, callback || function(error) { - if (error) - process.logger.error('Error while updating entities message : ' + - error.message, data); - }); +EntityProvider.prototype.updateOne = function(filter, data, callback) { + this.storage.updateOne(this.location, filter, data, function(error, total) { + this.executeCallback(callback, error, total); + }.bind(this)); }; /** - * Removes one or several entities. - * - * If the entity has the property "locked", it won't be removed. + * Removes entities. * * @method remove * @async - * @param {String|Array} ids Id(s) of the document(s) to remove from the collection - * @param {Function} callback The function to call when it's done - * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of deleted entities - */ -EntityProvider.prototype.remove = function(ids, callback) { - var filter = {}; - filter['locked'] = {$ne: true}; - filter['id'] = {$in: null}; - filter['id']['$in'] = (Array.isArray(ids)) ? ids : [ids]; - - this.database.remove(this.collection, filter, callback || function(error) { - if (error) - process.logger.error('Error while removing entities with message : ' + error.message, ids); - }); -}; - -/** - * Removes a property on all documents in the collection. - * - * If the entity has the property "locked", it won't be updated. - * - * @method removeProp - * @async - * @param {String} property The property name to remove - * @param {Function} callback The function to call when it's done + * @param {ResourceFilter} [filter] Rules to filter entities to remove + * @param {Function} [callback] The function to call when it's done * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of modified entities + * - **Number** The number of removed entities */ -EntityProvider.prototype.removeProp = function(property, callback) { - var filter = {}; - filter['locked'] = {$ne: true}; - - this.database.removeProp(this.collection, property, filter, callback || function(error) { - if (error) - process.logger.error('Error while removing property from entities(s) with message : ' + - error.message, property); - }); +EntityProvider.prototype.remove = function(filter, callback) { + this.storage.remove(this.location, filter, function(error, total) { + this.executeCallback(callback, error, total); + }.bind(this)); }; /** - * Increase an entity. + * Removes a field from entities. * - * If the entity has the property "locked", it won't be increased. - * - * @method increase + * @method removeField * @async - * @param {String} id The id of the entity to update - * @param {Object} data Object which key is the parameter to increase and value, amount of increase/decrease - * - Ex: {views: 56, priority: -5} - * @param {Function} callback The function to call when it's done + * @param {String} field The field to remove from entities + * @param {ResourceFilter} [filter] Rules to filter entities to update + * @param {Function} [callback] The function to call when it's done * - **Error** The error if an error occurred, null otherwise - * - **Number** The number of updated items + * - **Number** The number of updated entities */ -EntityProvider.prototype.increase = function(id, data, callback) { - var filter = {}; - filter['locked'] = {$ne: true}; - filter['id'] = id; - this.database.increase(this.collection, filter, data, callback || function(error) { - if (error) - process.logger.error('Error while increasing entities message : ' + - error.message, data); - }); +EntityProvider.prototype.removeField = function(field, filter, callback) { + this.storage.removeField(this.location, field, filter, function(error, total) { + this.executeCallback(callback, error, total); + }.bind(this)); }; diff --git a/lib/providers/Provider.js b/lib/providers/Provider.js index 32a76cd..33a092b 100644 --- a/lib/providers/Provider.js +++ b/lib/providers/Provider.js @@ -4,56 +4,55 @@ * @module providers */ -var Database = process.requireApi('lib/database/Database.js'); +var Storage = process.requireApi('lib/storages/Storage.js'); /** - * Defines the base provider for all providers which need to manipulate datas - * from a database. + * Defines the base provider for all providers. * - * // Implement a Provider named "CustomProvider" - * var util = require('util'); - * var openVeoApi = require('@openveo/api'); - * - * function CustomProvider(database) { - * CustomProvider.super_.call(this, database, 'customCollection'); - * } - * - * util.inherits(CustomProvider, openVeoApi.providers.Provider); + * A provider manages resources from its associated storage. * * @class Provider * @constructor - * @param {Database} database The database to use - * @param {String} collection The database's collection - * @throws {TypeError} If database and / or collection are not as expected + * @param {Storage} storage The storage to use to store provider resources + * @throws {TypeError} If storage is not valid */ -function Provider(database, collection) { +function Provider(storage) { Object.defineProperties(this, { /** - * The database to use. - * - * @property database - * @type Database - * @final - */ - database: {value: database}, - - /** - * The database's collection's name. + * The provider storage. * - * @property collection - * @type String + * @property storage + * @type Storage * @final */ - collection: {value: collection} + storage: {value: storage} }); - if (!this.collection) - throw new TypeError('A Provider needs a collection'); - - if (!(this.database instanceof Database)) - throw new TypeError('Database must be of type Database'); + if (!(this.storage instanceof Storage)) + throw new TypeError('storage must be of type Storage'); } module.exports = Provider; + +/** + * Executes the given callback or log the error message if no callback specified. + * + * It assumes that the second argument is the error. All arguments, except the callback itself, will be specified + * as arguments when executing the callback. + * + * @method executeCallback + * @param {Function} [callback] The function to execute + * @param {Error} [error] An eventual error to pass to the callback + * @param {Function} [callback] The function to call, if not specified the error is logged + */ +Provider.prototype.executeCallback = function() { + var args = Array.prototype.slice.call(arguments); + var callback = args.shift(); + var error = args[0]; + + if (callback) return callback.apply(null, args); + if (error instanceof Error) + process.logger.error('An error occured while executing callback with message: ' + error.message); +}; diff --git a/lib/providers/index.js b/lib/providers/index.js index 83774b6..b9a5533 100644 --- a/lib/providers/index.js +++ b/lib/providers/index.js @@ -1,7 +1,10 @@ 'use strict'; /** - * Base providers' to be used by all providers. + * Providers store resources they define. + * + * A provider is the protector of the resources it defines. It communicates with its associated storage to manipulate + * its resources. * * // Load module "providers" * var providers = require('@openveo/api').providers; diff --git a/lib/storages/ResourceFilter.js b/lib/storages/ResourceFilter.js new file mode 100644 index 0000000..dba862f --- /dev/null +++ b/lib/storages/ResourceFilter.js @@ -0,0 +1,408 @@ +'use strict'; + +/** + * @module storages + */ + +/** + * Defines a storage filter. + * + * A filter is a uniform way of filtering results common to all storages. + * A filter can contain only one "or" operation, one "nor" operation and one "and" operation. + * + * var filter = new ResourceFilter() + * .equal('field1', 42) + * .notEqual('field2', 42) + * .greaterThan('field3', 42) + * .greaterThanEqual('field4', 42) + * .in('field5', [42]) + * .lesserThan('field6', 42) + * .lesserThanEqual('field7', 42) + * .search('query') + * .or([ + * new ResourceFilter().equal('field8', 42), + * new ResourceFilter().notIn('field9', [42]) + * ]) + * .nor([ + * new ResourceFilter().equal('field10', 42), + * new ResourceFilter().notIn('field11', [42]) + * )], + * .and([ + * new ResourceFilter().equal('field12', 42), + * new ResourceFilter().notIn('field13', [42]) + * )]; + * + * @class ResourceFilter + * @constructor + */ +function ResourceFilter() { + Object.defineProperties(this, { + + /** + * The list of operations. + * + * @property operations + * @type Array + * @final + */ + operations: { + value: [] + } + + }); + +} + +module.exports = ResourceFilter; + +/** + * The available operators. + * + * @property OPERATORS + * @type Object + * @final + * @static + */ +ResourceFilter.OPERATORS = { + OR: 'or', + NOR: 'nor', + AND: 'and', + EQUAL: 'equal', + NOT_EQUAL: 'notEqual', + IN: 'in', + NOT_IN: 'notIn', + GREATER_THAN: 'greaterThan', + GREATER_THAN_EQUAL: 'greaterThanEqual', + LESSER_THAN: 'lesserThan', + LESSER_THAN_EQUAL: 'lesserThanEqual', + SEARCH: 'search' +}; +Object.freeze(ResourceFilter.OPERATORS); + +/** + * Validates the type of a value. + * + * @method isValidType + * @private + * @param {String} value The value to test + * @param {Array} The list of authorized types as strings + * @return {Boolean} true if valid, false otherwise + */ +function isValidType(value, authorizedTypes) { + var valueToString = Object.prototype.toString.call(value); + + for (var i = 0; i < authorizedTypes.length; i++) + if (valueToString === '[object ' + authorizedTypes[i] + ']') return true; + + return false; +} + +/** + * Adds a comparison operation to the filter. + * + * @method addComparisonOperation + * @private + * @param {String} field The name of the field + * @param {String|Number|Boolean|Date} value The value to compare the field to + * @param {String} Resource filter operator + * @param {Array} The list of authorized types as strings + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if field and / or value is not valid + */ +function addComparisonOperation(field, value, operator, authorizedTypes) { + if (!isValidType(field, ['String'])) throw new TypeError('Invalid field'); + if (!isValidType(value, authorizedTypes)) throw new TypeError('Invalid value'); + + this.operations.push({ + type: operator, + field: field, + value: value + }); + return this; +} + +/** + * Adds a logical operation to the filter. + * + * Only one logical operation can be added in a filter. + * + * @method addLogicalOperation + * @private + * @param {Array} filters The list of filters + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if field and / or value is not valid + */ +function addLogicalOperation(filters, operator) { + for (var i = 0; i < filters.length; i++) + if (!(filters[i] instanceof ResourceFilter)) throw new TypeError('Invalid filters'); + + if (this.hasOperation(operator)) { + + // This logical operator already exists in the list of operations + // Just add the new filters to the operator + + for (var operation of this.operations) { + if (operation.type === operator) + operation.filters = operation.filters.concat(filters); + } + + } else { + + // This logical operator does not exist yet in the list of operations + + this.operations.push({ + type: operator, + filters: filters + }); + } + + return this; +} + +/** + * Adds an equal operation to the filter. + * + * @method equal + * @param {String} field The name of the field + * @param {String|Number|Boolean|Date} value The value to compare the field to + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if field and / or value is not valid + */ +ResourceFilter.prototype.equal = function(field, value) { + return addComparisonOperation.call( + this, + field, + value, + ResourceFilter.OPERATORS.EQUAL, + ['String', 'Boolean', 'Date', 'Number'] + ); +}; + +/** + * Adds a "not equal" operation to the filter. + * + * @method equal + * @param {String} field The name of the field + * @param {String|Number|Boolean|Date} value The value to compare the field to + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if field and / or value is not valid + */ +ResourceFilter.prototype.notEqual = function(field, value) { + return addComparisonOperation.call( + this, + field, + value, + ResourceFilter.OPERATORS.NOT_EQUAL, + ['String', 'Boolean', 'Date', 'Number'] + ); +}; + +/** + * Adds a "greater than" operation to the filter. + * + * @method greaterThan + * @param {String} field The name of the field + * @param {String|Number|Boolean|Date} value The value to compare the field to + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if field and / or value is not valid + */ +ResourceFilter.prototype.greaterThan = function(field, value) { + return addComparisonOperation.call( + this, + field, + value, + ResourceFilter.OPERATORS.GREATER_THAN, + ['String', 'Boolean', 'Date', 'Number'] + ); +}; + +/** + * Adds a "greater than or equal" operation to the filter. + * + * @method greaterThanEqual + * @param {String} field The name of the field + * @param {String|Number|Boolean|Date} value The value to compare the field to + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if field and / or value is not valid + */ +ResourceFilter.prototype.greaterThanEqual = function(field, value) { + return addComparisonOperation.call( + this, + field, + value, + ResourceFilter.OPERATORS.GREATER_THAN_EQUAL, + ['String', 'Boolean', 'Date', 'Number'] + ); +}; + +/** + * Adds a "lesser than" operation to the filter. + * + * @method lesserThan + * @param {String} field The name of the field + * @param {String|Number|Boolean|Date} value The value to compare the field to + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if field and / or value is not valid + */ +ResourceFilter.prototype.lesserThan = function(field, value) { + return addComparisonOperation.call( + this, + field, + value, + ResourceFilter.OPERATORS.LESSER_THAN, + ['String', 'Boolean', 'Date', 'Number'] + ); +}; + +/** + * Adds a "lesser than equal" operation to the filter. + * + * @method lesserThanEqual + * @param {String} field The name of the field + * @param {String|Number|Boolean|Date} value The value to compare the field to + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if field and / or value is not valid + */ +ResourceFilter.prototype.lesserThanEqual = function(field, value) { + return addComparisonOperation.call( + this, + field, + value, + ResourceFilter.OPERATORS.LESSER_THAN_EQUAL, + ['String', 'Boolean', 'Date', 'Number'] + ); +}; + +/** + * Adds an "in" operation to the filter. + * + * @method in + * @param {String} field The name of the field + * @param {Array} value The value to compare the field to + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if field and / or value is not valid + */ +ResourceFilter.prototype.in = function(field, value) { + if (!isValidType(value, ['Array'])) throw new TypeError('Invalid value'); + + for (var i = 0; i < value.length; i++) + if (!isValidType(value[i], ['String', 'Boolean', 'Date', 'Number'])) throw new TypeError('Invalid value'); + + return addComparisonOperation.call(this, field, value, ResourceFilter.OPERATORS.IN, ['Array']); +}; + +/** + * Adds a "not in" operation to the filter. + * + * @method in + * @param {String} field The name of the field + * @param {Array} value The value to compare the field to + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if field and / or value is not valid + */ +ResourceFilter.prototype.notIn = function(field, value) { + if (!isValidType(value, ['Array'])) throw new TypeError('Invalid value'); + + for (var i = 0; i < value.length; i++) + if (!isValidType(value[i], ['String', 'Boolean', 'Date', 'Number'])) throw new TypeError('Invalid value'); + + return addComparisonOperation.call(this, field, value, ResourceFilter.OPERATORS.NOT_IN, ['Array']); +}; + +/** + * Adds a "or" operation to the filter. + * + * @method or + * @param {Array} filters The list of filters + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if filters are not valid + */ +ResourceFilter.prototype.or = function(filters) { + return addLogicalOperation.call(this, filters, ResourceFilter.OPERATORS.OR); +}; + +/** + * Adds a "nor" operation to the filter. + * + * @method nor + * @param {Array} filters The list of filters + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if filters are not valid + */ +ResourceFilter.prototype.nor = function(filters) { + return addLogicalOperation.call(this, filters, ResourceFilter.OPERATORS.NOR); +}; + +/** + * Adds an "and" operation to the filter. + * + * @method and + * @param {Array} filters The list of filters + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if filters are not valid + */ +ResourceFilter.prototype.and = function(filters) { + return addLogicalOperation.call(this, filters, ResourceFilter.OPERATORS.AND); +}; + +/** + * Adds a "search" operation to the filter. + * + * @method search + * @param {String} value The search query + * @return {ResourceFilter} The actual filter + * @throws {TypeError} An error if value is not a String + */ +ResourceFilter.prototype.search = function(value) { + if (!isValidType(value, ['String'])) throw new TypeError('Invalid value'); + + this.operations.push({ + type: ResourceFilter.OPERATORS.SEARCH, + value: value + }); + + return this; +}; + +/** + * Tests if an operation has already been specified. + * + * @method hasOperation + * @param {String} Operation operator + * @return {Boolean} true if the operation has already been added to this filter, false otherwise + */ +ResourceFilter.prototype.hasOperation = function(operator) { + for (var i = 0; i < this.operations.length; i++) + if (this.operations[i].type === operator) return true; + + return false; +}; + +/** + * Gets an operation from filter or sub filters. + * + * @method getComparisonOperation + * @param {String} Operation operator + * @param {String} Operation field + * @return {Object|Null} The operation with: + * -**String** type The operation type + * -**String** field The operation field + * -**String|Number|Boolean|Date** value The operation value + */ +ResourceFilter.prototype.getComparisonOperation = function(operator, field) { + for (var i = 0; i < this.operations.length; i++) { + if ([ + ResourceFilter.OPERATORS.OR, + ResourceFilter.OPERATORS.NOR, + ResourceFilter.OPERATORS.AND + ].indexOf(this.operations[i].type) >= 0) { + for (var j = 0; j < this.operations[i].filters.length; j++) { + var result = this.operations[i].filters[j].getComparisonOperation(operator, field); + if (result) return result; + } + } else if (this.operations[i].type === operator && (!field || this.operations[i].field === field)) + return this.operations[i]; + } + + return null; +}; diff --git a/lib/storages/Storage.js b/lib/storages/Storage.js new file mode 100644 index 0000000..2c518b3 --- /dev/null +++ b/lib/storages/Storage.js @@ -0,0 +1,152 @@ +'use strict'; + +/** + * @module storages + */ + +/** + * Defines base storage for all storages. + * + * A storage is capable of performing CRUD (Create Read Update Delete) operations on resources. + * + * This should not be used directly, use one of its subclasses instead. + * + * @class Storage + * @constructor + * @param {Object} configuration Storage configuration which depends on the Storage type + * @throws {TypeError} If configuration is missing + */ +function Storage(configuration) { + if (Object.prototype.toString.call(configuration) !== '[object Object]') + throw new TypeError('Storage configuration must be an Object'); + + Object.defineProperties(this, { + + /** + * The storage configuration. + * + * @property configuration + * @type Object + * @final + */ + configuration: { + value: configuration + } + + }); + +} + +module.exports = Storage; + +/** + * Adds resources to the storage. + * + * @method add + * @async + * @param {String} location The storage location where the resource will be added + * @param {Array} resources The list of resources to store + * @param {Function} [callback] The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Number** The total amount of resources inserted + * - **Array** The list of inserted resources + */ +Storage.prototype.add = function(location, resources, callback) { + throw new Error('add method not implemented for this Storage'); +}; + +/** + * Fetches resources from the storage. + * + * @method get + * @async + * @param {String} location The storage location where to search for resources + * @param {ResourceFilter} [filter] Rules to filter resources + * @param {Object} [fields] Expected resource fields to be included or excluded from the response, by default all + * fields are returned. Only "exclude" or "include" can be specified, not both + * @param {Array} [fields.include] The list of fields to include in the response, all other fields are excluded. + * @param {Array} [fields.exclude] The list of fields to exclude from response, all other fields are included. Ignored + * if include is also specified. + * @param {Number} [limit] A limit number of resources to retrieve (10 by default) + * @param {Number} [page] The page number started at 0 for the first page + * @param {Object} [sort] The list of fields to sort by with the field name as key and the sort order as + * value (e.g. {field1: 'asc', field2: 'desc'}) + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Array** The list of retrieved resources + * - **Object** Pagination information + * - **Number** limit The specified limit + * - **Number** page The actual page + * - **Number** pages The total number of pages + * - **Number** size The total number of resources + */ +Storage.prototype.get = function(location, filter, fields, limit, page, sort, callback) { + throw new Error('get method not implemented for this Storage'); +}; + +/** + * Fetches a single resource from the storage. + * + * @method getOne + * @async + * @param {String} location The storage location where to search for the resource + * @param {ResourceFilter} [filter] Rules to filter resources + * @param {Object} [fields] Expected resource fields to be included or excluded from the response, by default all + * fields are returned. Only "exclude" or "include" can be specified, not both + * @param {Array} [fields.include] The list of fields to include in the response, all other fields are excluded + * @param {Array} [fields.exclude] The list of fields to exclude from response, all other fields are included. Ignored + * if include is also specified. + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Object** The resource + */ +Storage.prototype.getOne = function(location, filter, fields, callback) { + throw new Error('getOne method not implemented for this Storage'); +}; + +/** + * Updates a resource in the storage. + * + * @method updateOne + * @async + * @param {String} location The storage location where to find the resource to update + * @param {ResourceFilter} filter Rules to filter the resource to update + * @param {Object} data The modifications to perform + * @param {Function} [callback] The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Number** 1 if everything went fine + */ +Storage.prototype.updateOne = function(location, filter, data, callback) { + throw new Error('updateOne method not implemented for this Storage'); +}; + +/** + * Removes resources from the storage. + * + * @method remove + * @async + * @param {String} location The storage location where to find the resources to remove + * @param {ResourceFilter} filter Rules to filter resources to remove + * @param {Function} [callback] The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Number** The number of removed resources + */ +Storage.prototype.remove = function(location, filter, callback) { + throw new Error('remove method not implemented for this Storage'); +}; + +/** + * Removes a field from resources of a storage location. + * + * @method removeField + * @async + * @param {String} location The storage location where to find the resources + * @param {String} field The field to remove from resources + * @param {ResourceFilter} [filter] Rules to filter resources to update + * @param {Function} [callback] The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Number** The number of updated resources + */ +Storage.prototype.removeField = function(location, field, filter, callback) { + throw new Error('removeField method not implemented for this Storage'); +}; diff --git a/lib/storages/databases/Database.js b/lib/storages/databases/Database.js new file mode 100644 index 0000000..1abcd0e --- /dev/null +++ b/lib/storages/databases/Database.js @@ -0,0 +1,171 @@ +'use strict'; + +/** + * @module storages + */ + +var util = require('util'); +var Storage = process.requireApi('lib/storages/Storage.js'); + +/** + * Defines base database for all databases. + * + * This should not be used directly, use one of its subclasses instead. + * + * @class Database + * @extends Storage + * @constructor + * @param {Object} configuration A database configuration object depending on the database type + * @param {String} configuration.type The database type + * @param {String} configuration.host Database server host + * @param {Number} configuration.port Database server port + * @param {String} configuration.database The name of the database + * @param {String} configuration.username The name of the database user + * @param {String} configuration.password The password of the database user + */ +function Database(configuration) { + Database.super_.call(this, configuration); + + Object.defineProperties(this, { + + /** + * Database host. + * + * @property host + * @type String + * @final + */ + host: {value: configuration.host}, + + /** + * Database port. + * + * @property port + * @type Number + * @final + */ + port: {value: configuration.port}, + + /** + * Database name. + * + * @property name + * @type String + * @final + */ + name: {value: configuration.database}, + + /** + * Database user name. + * + * @property username + * @type String + * @final + */ + username: {value: configuration.username}, + + /** + * Database user password. + * + * @property password + * @type String + * @final + */ + password: {value: configuration.password} + + }); +} + +module.exports = Database; +util.inherits(Database, Storage); + +/** + * Establishes connection to the database. + * + * @method connect + * @async + * @param {Function} callback The function to call when connection to the database is established + * - **Error** The error if an error occurred, null otherwise + */ +Database.prototype.connect = function(callback) { + throw new Error('connect method not implemented for this Database'); +}; + +/** + * Closes connection to the database. + * + * @method close + * @async + * @param {Function} callback The function to call when connection is closed + * - **Error** The error if an error occurred, null otherwise + */ +Database.prototype.close = function(callback) { + throw new Error('close method not implemented for this Database'); +}; + +/** + * Gets the list of indexes for a collection. + * + * @method getIndexes + * @async + * @param {String} collection The collection to work on + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Array** The list of indexes + */ +Database.prototype.getIndexes = function(collection, callback) { + throw new Error('getIndexes method not implemented for this Database'); +}; + +/** + * Creates indexes for a collection. + * + * @method createIndexes + * @async + * @param {String} collection The collection to work on + * @param {Array} indexes A list of indexes using MongoDB format + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Object** Information about the operation + */ +Database.prototype.createIndexes = function(collection, indexes, callback) { + throw new Error('createIndexes method not implemented for this Database'); +}; + +/** + * Gets an express-session store for the database. + * + * @method getStore + * @param {String} collection The collection to work on + * @return {Store} An express-session store + */ +Database.prototype.getStore = function(collection) { + throw new Error('getStore method not implemented for this Database'); +}; + +/** + * Renames a collection. + * + * @method renameCollection + * @async + * @param {String} collection The collection to work on + * @param {String} target The new name of the collection + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + */ +Database.prototype.renameCollection = function(collection, target, callback) { + throw new Error('renameCollection method not implemented for this Database'); +}; + +/** + * Removes a collection from the database. + * + * @method removeCollection + * @async + * @param {String} collection The collection to work on + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + */ +Database.prototype.removeCollection = function(collection, callback) { + throw new Error('removeCollection method not implemented for this Database'); +}; diff --git a/lib/storages/databases/databaseErrors.js b/lib/storages/databases/databaseErrors.js new file mode 100644 index 0000000..d682c8e --- /dev/null +++ b/lib/storages/databases/databaseErrors.js @@ -0,0 +1,53 @@ +'use strict'; + +/** + * @module storages + */ + +/** + * The list of common database errors with, for each error, its associated hexadecimal code. + * + * var DATABASE_ERRORS = require('@openveo/api').storages.databaseErrors; + * + * @class database-errors + * @static + */ +var DATABASE_ERRORS = { + + /** + * An error occurring when renaming a collection which does not exist. + * + * @property RENAME_COLLECTION_NOT_FOUND_ERROR + * @type Object + * @final + */ + RENAME_COLLECTION_NOT_FOUND_ERROR: { + code: 0x000 + }, + + /** + * An error occurring when removing a collection which does not exist. + * + * @property REMOVE_COLLECTION_NOT_FOUND_ERROR + * @type Object + * @final + */ + REMOVE_COLLECTION_NOT_FOUND_ERROR: { + code: 0x001 + }, + + /** + * An error occurring when an unsupported ResourceFilter operation is used. + * + * @property BUILD_FILTERS_UNKNOWN_OPERATION_ERROR + * @type Object + * @final + */ + BUILD_FILTERS_UNKNOWN_OPERATION_ERROR: { + code: 0x002 + } + +}; + +Object.freeze(DATABASE_ERRORS); +module.exports = DATABASE_ERRORS; diff --git a/lib/storages/databases/mongodb/MongoDatabase.js b/lib/storages/databases/mongodb/MongoDatabase.js new file mode 100644 index 0000000..7720708 --- /dev/null +++ b/lib/storages/databases/mongodb/MongoDatabase.js @@ -0,0 +1,547 @@ +'use strict'; + +/** + * @module storages + */ + +var util = require('util'); +var mongodb = require('mongodb'); +var session = require('express-session'); +var MongoStore = require('connect-mongo')(session); +var Database = process.requireApi('lib/storages/databases/Database.js'); +var databaseErrors = process.requireApi('lib/storages/databases/databaseErrors.js'); +var ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js'); +var StorageError = process.requireApi('lib/errors/StorageError.js'); +var MongoClient = mongodb.MongoClient; + +/** + * Defines a MongoDB Database. + * + * @class MongoDatabase + * @extends Database + * @constructor + * @param {Object} configuration A database configuration object + * @param {String} configuration.host MongoDB server host + * @param {Number} configuration.port MongoDB server port + * @param {String} configuration.database The name of the database + * @param {String} configuration.username The name of the database user + * @param {String} configuration.password The password of the database user + * @param {String} [configuration.replicaSet] The name of the ReplicaSet + * @param {String} [configuration.seedlist] The comma separated list of secondary servers + */ +function MongoDatabase(configuration) { + MongoDatabase.super_.call(this, configuration); + + Object.defineProperties(this, { + + /** + * The name of the replica set. + * + * @property replicaSet + * @type String + * @final + */ + replicaSet: {value: configuration.replicaSet}, + + /** + * A comma separated list of secondary servers. + * + * @property seedlist + * @type String + * @final + */ + seedlist: {value: configuration.seedlist}, + + /** + * The connected database. + * + * @property database + * @type Db + * @final + */ + db: { + value: null, + writable: true + } + + }); +} + +module.exports = MongoDatabase; +util.inherits(MongoDatabase, Database); + +/** + * Builds MongoDb filter from a ResourceFilter. + * + * @method buildFilter + * @static + * @param {ResourceFilter} resourceFilter The common resource filter + * @return {Object} The MongoDB like filter description object + * @throws {TypeError} If an operation is not supported + */ +MongoDatabase.buildFilter = function(resourceFilter) { + var filter = {}; + + if (!resourceFilter) return filter; + + /** + * Builds a list of filters. + * + * @param {Array} filters The list of filters to build + * @return {Array} The list of built filters + */ + function buildFilters(filters) { + var builtFilters = []; + filters.forEach(function(filter) { + builtFilters.push(MongoDatabase.buildFilter(filter)); + }); + return builtFilters; + } + + resourceFilter.operations.forEach(function(operation) { + switch (operation.type) { + case ResourceFilter.OPERATORS.EQUAL: + if (!filter[operation.field]) filter[operation.field] = {}; + filter[operation.field]['$eq'] = operation.value; + break; + case ResourceFilter.OPERATORS.NOT_EQUAL: + if (!filter[operation.field]) filter[operation.field] = {}; + filter[operation.field]['$ne'] = operation.value; + break; + case ResourceFilter.OPERATORS.GREATER_THAN: + if (!filter[operation.field]) filter[operation.field] = {}; + filter[operation.field]['$gt'] = operation.value; + break; + case ResourceFilter.OPERATORS.GREATER_THAN_EQUAL: + if (!filter[operation.field]) filter[operation.field] = {}; + filter[operation.field]['$gte'] = operation.value; + break; + case ResourceFilter.OPERATORS.LESSER_THAN: + if (!filter[operation.field]) filter[operation.field] = {}; + filter[operation.field]['$lt'] = operation.value; + break; + case ResourceFilter.OPERATORS.LESSER_THAN_EQUAL: + if (!filter[operation.field]) filter[operation.field] = {}; + filter[operation.field]['$lte'] = operation.value; + break; + case ResourceFilter.OPERATORS.IN: + if (!filter[operation.field]) filter[operation.field] = {}; + filter[operation.field]['$in'] = operation.value; + break; + case ResourceFilter.OPERATORS.NOT_IN: + if (!filter[operation.field]) filter[operation.field] = {}; + filter[operation.field]['$nin'] = operation.value; + break; + case ResourceFilter.OPERATORS.AND: + filter['$and'] = buildFilters(operation.filters); + break; + case ResourceFilter.OPERATORS.OR: + filter['$or'] = buildFilters(operation.filters); + break; + case ResourceFilter.OPERATORS.NOR: + filter['$nor'] = buildFilters(operation.filters); + break; + case ResourceFilter.OPERATORS.SEARCH: + filter['$text'] = { + $search: operation.value + }; + break; + default: + throw new StorageError( + 'Operation ' + operation.type + ' not supported', + databaseErrors.BUILD_FILTERS_UNKNOWN_OPERATION_ERROR + ); + } + }); + + return filter; +}; + +/** + * Builds MongoDb fields projection. + * + * @method buildFields + * @static + * @param {Array} fields The list of fields to include or exclude + * @param {Boolean} doesInclude true to include fields and exclude all other fields or false to exclude fields and + * include all other fields + * @return {Object} The MongoDB projection description object + */ +MongoDatabase.buildFields = function(fields, doesInclude) { + var projection = {_id: 0}; + + if (!fields) return projection; + + fields.forEach(function(field) { + projection[field] = doesInclude ? 1 : 0; + }); + + return projection; +}; + +/** + * Builds MongoDB sort object. + * + * Concretely it just replaces "asc" by 1 and "desc" by -1. + * + * @method buildSort + * @static + * @param {Object} [sort] The list of fields to sort by with the field name as key and the sort order as + * value (e.g. {field1: 'asc', field2: 'desc'}) + * @return {Object} The MongoDB sort description object + */ +MongoDatabase.buildSort = function(sort) { + var mongoSort = {}; + + if (!sort) return mongoSort; + for (var field in sort) mongoSort[field] = sort[field] === 'asc' ? 1 : -1; + + return mongoSort; +}; + +/** + * Establishes connection to the database. + * + * @method connect + * @async + * @param {Function} callback The function to call when connection to the database is established + * - **Error** The error if an error occurred, null otherwise + */ +MongoDatabase.prototype.connect = function(callback) { + var self = this; + var connectionUrl = 'mongodb://' + this.username + ':' + this.password + '@' + this.host + ':' + this.port; + var database = '/' + this.name; + var seedlist = ',' + this.seedlist; + var replicaset = '?replicaSet=' + this.replicaSet + '&readPreference=secondary'; + + // Connect to a Replica Set or not + if (this.seedlist != undefined && + this.seedlist != '' && + this.replicaSet != undefined && + this.replicaSet != '') { + connectionUrl = connectionUrl + seedlist + database + replicaset; + } else + connectionUrl = connectionUrl + database; + + MongoClient.connect(connectionUrl, function(error, db) { + + // Connection succeeded + if (!error) + self.db = db; + + callback(error); + }); + +}; + +/** + * Closes connection to the database. + * + * @method close + * @async + * @param {Function} callback The function to call when connection is closed + * - **Error** The error if an error occurred, null otherwise + */ +MongoDatabase.prototype.close = function(callback) { + this.db.close(callback); +}; + +/** + * Inserts several documents into a collection. + * + * @method add + * @async + * @param {String} collection The collection to work on + * @param {Array} documents Document(s) to insert into the collection + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Number** The total amount of documents inserted + * - **Array** The list of inserted documents + */ +MongoDatabase.prototype.add = function(collection, documents, callback) { + this.db.collection(collection, function(error, fetchedCollection) { + if (error) + return callback(error); + + fetchedCollection.insertMany(documents, function(error, result) { + if (error) + callback(error); + else + callback(null, result.insertedCount, result.ops); + }); + }); +}; + +/** + * Removes several documents from a collection. + * + * @method remove + * @async + * @param {String} collection The collection to work on + * @param {ResourceFilter} [filter] Rules to filter documents to remove + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Number** The number of deleted documents + */ +MongoDatabase.prototype.remove = function(collection, filter, callback) { + filter = MongoDatabase.buildFilter(filter); + + this.db.collection(collection, function(error, fetchedCollection) { + if (error) + return callback(error); + + fetchedCollection.deleteMany(filter, function(error, result) { + if (error) + callback(error); + else + callback(null, result.deletedCount); + }); + }); +}; + +/** + * Removes a property from documents of a collection. + * + * @method removeField + * @async + * @param {String} collection The collection to work on + * @param {String} property The name of the property to remove + * @param {ResourceFilter} [filter] Rules to filter documents to update + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Number** The number of updated documents + */ +MongoDatabase.prototype.removeField = function(collection, property, filter, callback) { + filter = MongoDatabase.buildFilter(filter); + filter[property] = {$exists: true}; + + var update = {}; + update['$unset'] = {}; + update['$unset'][property] = ''; + + this.db.collection(collection, function(error, fetchedCollection) { + if (error) + return callback(error); + + fetchedCollection.updateMany(filter, update, function(error, result) { + if (error) + callback(error); + else + callback(null, result.modifiedCount); + }); + }); +}; + +/** + * Updates a document from collection. + * + * @method updateOne + * @async + * @param {String} collection The collection to work on + * @param {ResourceFilter} [filter] Rules to filter the document to update + * @param {Object} data The modifications to perform + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Number** 1 if everything went fine + */ +MongoDatabase.prototype.updateOne = function(collection, filter, data, callback) { + var update = {$set: data}; + filter = MongoDatabase.buildFilter(filter); + + this.db.collection(collection, function(error, fetchedCollection) { + if (error) return callback(error); + + fetchedCollection.updateOne(filter, update, function(error, result) { + if (error) + callback(error); + else + callback(null, result.modifiedCount); + }); + }); +}; + +/** + * Fetches documents from the collection. + * + * @method get + * @async + * @param {String} collection The collection to work on + * @param {ResourceFilter} [filter] Rules to filter documents + * @param {Object} [fields] Expected resource fields to be included or excluded from the response, by default all + * fields are returned. Only "exclude" or "include" can be specified, not both + * @param {Array} [fields.include] The list of fields to include in the response, all other fields are excluded + * @param {Array} [fields.exclude] The list of fields to exclude from response, all other fields are included. Ignored + * if include is also specified. + * @param {Number} [limit] A limit number of documents to retrieve (10 by default) + * @param {Number} [page] The page number started at 0 for the first page + * @param {Object} sort The list of fields to sort by with the field name as key and the sort order as + * value (e.g. {field1: 'asc', field2: 'desc'}) + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Array** The list of retrieved documents + * - **Object** Pagination information + * - **Number** limit The specified limit + * - **Number** page The actual page + * - **Number** pages The total number of pages + * - **Number** size The total number of documents + */ +MongoDatabase.prototype.get = function(collection, filter, fields, limit, page, sort, callback) { + this.db.collection(collection, function(error, fetchedCollection) { + if (error) return callback(error); + + limit = limit || 10; + fields = fields || {}; + page = page || 0; + filter = MongoDatabase.buildFilter(filter); + sort = MongoDatabase.buildSort(sort); + var projection = MongoDatabase.buildFields(fields.include || fields.exclude, fields.include ? true : false); + var skip = limit * page || 0; + + var cursor = fetchedCollection.find(filter).project(projection).sort(sort).skip(skip).limit(limit); + cursor.toArray(function(toArrayError, documents) { + if (toArrayError) return callback(toArrayError); + + cursor.count(false, null, function(countError, count) { + if (countError) callback(countError); + + callback(error, documents || [], { + limit: limit, + page: page, + pages: Math.ceil(count / limit), + size: count + }); + }); + }); + + }); +}; + +/** + * Fetches a single document from the storage. + * + * @method getOne + * @async + * @param {String} collection The collection to work on + * @param {ResourceFilter} [filter] Rules to filter documents + * @param {Object} [fields] Expected document fields to be included or excluded from the response, by default all + * fields are returned. Only "exclude" or "include" can be specified, not both + * @param {Array} [fields.include] The list of fields to include in the response, all other fields are excluded + * @param {Array} [fields.exclude] The list of fields to exclude from response, all other fields are included. Ignored + * if include is also specified. + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Object** The document + */ +MongoDatabase.prototype.getOne = function(collection, filter, fields, callback) { + filter = MongoDatabase.buildFilter(filter); + fields = fields || {}; + var projection = MongoDatabase.buildFields(fields.include || fields.exclude, fields.include ? true : false); + + this.db.collection(collection, function(error, fetchedCollection) { + if (error) return callback(error); + fetchedCollection.findOne(filter, projection, callback); + }); +}; + +/** + * Gets the list of indexes for a collection. + * + * @method getIndexes + * @async + * @param {String} collection The collection to work on + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Array** The list of indexes + */ +MongoDatabase.prototype.getIndexes = function(collection, callback) { + this.db.collection(collection, function(error, fetchedCollection) { + if (error) + return callback(error); + + fetchedCollection.indexes(callback); + }); +}; + +/** + * Creates indexes for a collection. + * + * @method createIndexes + * @async + * @param {String} collection The collection to work on + * @param {Array} indexes A list of indexes using MongoDB format + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + * - **Object** Information about the operation + */ +MongoDatabase.prototype.createIndexes = function(collection, indexes, callback) { + this.db.collection(collection, function(error, fetchedCollection) { + if (error) + return callback(error); + + fetchedCollection.createIndexes(indexes, callback); + }); +}; + +/** + * Gets an express-session store for this database. + * + * @method getStore + * @param {String} collection The collection to work on + * @return {Store} An express-session store + */ +MongoDatabase.prototype.getStore = function(collection) { + return new MongoStore({db: this.db, collection: collection}); +}; + +/** + * Renames a collection. + * + * @method renameCollection + * @async + * @param {String} collection The collection to work on + * @param {String} target The new name of the collection + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + */ +MongoDatabase.prototype.renameCollection = function(collection, target, callback) { + var self = this; + + this.db.listCollections({name: collection}).toArray(function(error, collections) { + if (error) return callback(error); + if (!collections || !collections.length) { + return callback( + new StorageError('Collection "' + collection + '" not found', databaseErrors.RENAME_COLLECTION_NOT_FOUND_ERROR) + ); + } + + self.db.collection(collection, function(error, fetchedCollection) { + if (error) return callback(error); + + fetchedCollection.rename(target, function(error) { + callback(error); + }); + }); + }); +}; + +/** + * Removes a collection from the database. + * + * @method removeCollection + * @async + * @param {String} collection The collection to work on + * @param {Function} callback The function to call when it's done + * - **Error** The error if an error occurred, null otherwise + */ +MongoDatabase.prototype.removeCollection = function(collection, callback) { + this.db.listCollections({name: collection}).toArray(function(error, collections) { + if (error) return callback(error); + if (!collections || !collections.length) { + return callback( + new StorageError('Collection "' + collection + '" not found', databaseErrors.REMOVE_COLLECTION_NOT_FOUND_ERROR) + ); + } + + this.db.dropCollection(collection, callback); + }.bind(this)); +}; diff --git a/lib/storages/factory.js b/lib/storages/factory.js new file mode 100644 index 0000000..3087532 --- /dev/null +++ b/lib/storages/factory.js @@ -0,0 +1,37 @@ +'use strict'; + +/** + * @module storages + */ + +/** + * Defines a factory to get an instance of a {{#crossLink "Storage"}}{{/crossLink}}. + * + * // Create a new Storage instance + * var db = openVeoApi.storages.factory.get('mongodb', mongoDbConfiguration); + * + * @class factory + * @static + */ + +/** + * Gets a Storage instance. + * + * @method get + * @static + * @param {String} type The expected storage type, could be "mongodb" + * @param {Object} configuration A storage configuration object which depends on the storage type + * @return {Storage} The Storage instance + * @throws {TypeError} If the specified storage type does not exist + */ +module.exports.get = function(type, configuration) { + switch (type) { + + case 'mongodb': + var MongoDatabase = process.requireApi('lib/storages/databases/mongodb/MongoDatabase.js'); + return new MongoDatabase(configuration); + + default: + throw new TypeError('Unknown Storage type'); + } +}; diff --git a/lib/storages/index.js b/lib/storages/index.js new file mode 100644 index 0000000..e71acef --- /dev/null +++ b/lib/storages/index.js @@ -0,0 +1,20 @@ +'use strict'; + +/** + * Storages hold different ways of storing a resource. + * + * A storage is capable of performing CRUD (Create Read Update Delete) operations to manage a set of + * resources. A storage doesn't have any knowledge about the resource, it just stores it. + * + * // Load module "storages" + * var storage = require('@openveo/api').storages; + * + * @module storages + * @main storages + */ + +module.exports.Storage = process.requireApi('lib/storages/Storage.js'); +module.exports.Database = process.requireApi('lib/storages/databases/Database.js'); +module.exports.ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js'); +module.exports.factory = process.requireApi('lib/storages/factory.js'); +module.exports.databaseErrors = process.requireApi('lib/storages/databases/databaseErrors.js'); diff --git a/lib/util.js b/lib/util.js old mode 100755 new mode 100644 index 257f75d..a6ed298 --- a/lib/util.js +++ b/lib/util.js @@ -299,7 +299,7 @@ module.exports.shallowValidateObject = function(objectToAnalyze, validationDescr case 'array': var arrayType = /array<([^>]*)>/.exec(expectedProperty.type)[1]; - if (typeof value === 'string' || typeof value === 'number') { + if ((typeof value === 'string' || typeof value === 'number') && arrayType !== 'object') { value = arrayType === 'string' ? String(value) : parseInt(value); value = value ? [value] : null; } else if (Object.prototype.toString.call(value) === '[object Array]') { @@ -323,7 +323,9 @@ module.exports.shallowValidateObject = function(objectToAnalyze, validationDescr } value = arrayValues.length ? arrayValues : null; - } else + } else if (typeof value !== 'undefined') + throw new Error('Property ' + name + ' must be a "' + expectedProperty.type + '"'); + else value = null; break; diff --git a/package.json b/package.json old mode 100755 new mode 100644 index 485b45b..21e10bb --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openveo/api", - "version": "4.3.1", + "version": "5.0.0", "description": "API for OpenVeo plugins", "keywords": [ "openveo", @@ -32,11 +32,14 @@ "passport-strategy": "1.0.0", "passport-ldapauth": "2.0.0", "passport-local": "1.0.0", - "xml2js": "0.4.17" + "xml2js": "0.4.17", + "gm": "1.23.0", + "imagemagick": "0.1.3" }, "devDependencies": { "chai": "4.0.2", "chai-spies": "0.7.1", + "esprima": "4.0.0", "grunt": "1.0.1", "grunt-contrib-yuidoc": "1.0.0", "grunt-eslint": "19.0.0", @@ -44,7 +47,8 @@ "grunt-mocha-test": "0.13.2", "mocha": "3.2.0", "pre-commit": "1.2.2", - "yuidoc-theme-blue": "0.1.9" + "yuidoc-theme-blue": "0.1.9", + "mock-require": "3.0.1" }, "scripts": { "eslint": "grunt eslint", diff --git a/tasks/mochaTest.js b/tasks/mochaTest.js index e975edf..8ea5059 100644 --- a/tasks/mochaTest.js +++ b/tasks/mochaTest.js @@ -11,15 +11,15 @@ module.exports = { 'tests/server/init.js', 'tests/server/api/*.js', 'tests/server/controllers/*.js', - 'tests/server/database/*.js', 'tests/server/emitters/*.js', 'tests/server/fileSystem/*.js', - 'tests/server/models/*.js', 'tests/server/providers/*.js', 'tests/server/socket/*.js', 'tests/server/grunt/*.js', 'tests/server/util/*.js', - 'tests/server/plugin/*.js' + 'tests/server/plugin/*.js', + 'tests/server/middlewares/*.js', + 'tests/server/storages/*.js' ] } diff --git a/tests/server/.eslintrc b/tests/server/.eslintrc index 946c75e..6add23e 100644 --- a/tests/server/.eslintrc +++ b/tests/server/.eslintrc @@ -1,23 +1,29 @@ { "env": { - + // Add mocha environment globals "mocha": true - + }, "globals": { - + // Add chai to environment globals but do not authorize to overwrite it chai: false, - assert: false - + assert: false, + + // Add angular to environment globals but do not authorize to overwrite it, + angular: false + }, "rules": { - + // Require JSDoc comment - "require-jsdoc": 0 - - } - + "require-jsdoc": 0, + + // Allow synchronous methods + "no-sync": 0, + + } + } \ No newline at end of file diff --git a/tests/server/controllers/ContentController.js b/tests/server/controllers/ContentController.js index a9678c1..cddf633 100644 --- a/tests/server/controllers/ContentController.js +++ b/tests/server/controllers/ContentController.js @@ -1,340 +1,1593 @@ 'use strict'; var util = require('util'); -var assert = require('chai').assert; +var chai = require('chai'); +var spies = require('chai-spies'); var ContentController = process.requireApi('lib/controllers/ContentController.js'); -var AccessError = process.requireApi('lib/errors/AccessError.js'); -var EntityModel = process.requireApi('lib/models/EntityModel.js'); -var EntityProvider = process.requireApi('lib/providers/EntityProvider.js'); -var Database = process.requireApi('lib/database/Database.js'); +var ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js'); +var httpErrors = process.requireApi('lib/controllers/httpErrors.js'); +var assert = chai.assert; + +chai.should(); +chai.use(spies); -// ContentController.js describe('ContentController', function() { - var TestEntityModel; - var TestEntityProvider; + var ProviderMock; var TestContentController; var testContentController; - - // Mocks + var expectedEntities; + var expectedPagination; + var expectedCount = 42; + var response; + var request; + var superAdminId = '0'; + var anonymousId = '1'; + var manageContentsPermissionId = 'manage-contents'; + + // Initiates mocks beforeEach(function() { - TestEntityModel = function(provider) { - TestEntityModel.super_.call(this, provider); + ProviderMock = { + get: function(filter, fields, limit, page, sort, callback) { + callback(null, expectedEntities, expectedPagination); + }, + getOne: function(filter, fields, callback) { + callback(null, expectedEntities[0]); + }, + update: function(filter, data, callback) { + callback(null, expectedCount); + }, + add: function(entities, callback) { + callback(null, expectedCount); + }, + remove: chai.spy(function(filter, callback) { + callback(null, expectedCount); + }), + removeField: function(field, filter, callback) { + callback(null, expectedCount); + }, + updateOne: chai.spy(function(filter, data, callback) { + callback(null, 1); + }) + }; + + response = { + send: function() {} + }; + + request = { + user: { + id: '42' + }, + query: {}, + params: {} + }; + + TestContentController = function() { + TestContentController.super_.call(this); + }; + + TestContentController.prototype.getProvider = function() { + return ProviderMock; + }; + + TestContentController.prototype.getSuperAdminId = function() { + return superAdminId; }; - TestEntityProvider = function(database) { - TestEntityProvider.super_.call(this, database, 'test_collection'); + TestContentController.prototype.getAnonymousId = function() { + return anonymousId; }; - TestContentController = function(ModelConstructor, ProviderConstructor) { - TestContentController.super_.call(this, ModelConstructor, ProviderConstructor); + TestContentController.prototype.getManageContentsPermissionId = function() { + return manageContentsPermissionId; }; - TestContentController.prototype.getModel = function() { - return new TestEntityModel(new TestEntityProvider(new Database({}))); + TestContentController.prototype.isUserManager = function() { + return false; }; - util.inherits(TestEntityModel, EntityModel); - util.inherits(TestEntityProvider, EntityProvider); util.inherits(TestContentController, ContentController); }); // Prepare tests using mocks beforeEach(function() { testContentController = new TestContentController(); + expectedEntities = []; }); - // getEntitiesAction method describe('getEntitiesAction', function() { - it('should send the list of entities returned by the model', function(done) { - var expectedEntities = {}; - TestEntityModel.prototype.get = function(filter, callback) { - callback(null, expectedEntities); - }; + it('should send the list of entities with pagination', function(done) { + expectedEntities = [{}]; + expectedPagination = {}; - var response = { - send: function(entities) { - assert.strictEqual(entities.entities, expectedEntities, 'Expected a list of entities'); + response = { + send: function(result) { + assert.strictEqual(result.entities, expectedEntities, 'Expected a list of entities'); + assert.strictEqual(result.pagination, expectedPagination, 'Expected pagination'); done(); } }; - testContentController.getEntitiesAction({}, response, function(error) { + testContentController.getEntitiesAction(request, response, function(error) { assert.ok(false, 'Unexpected error : ' + error.message); }); }); - it('should send an HTTP forbidden error if model return an access error', function(done) { - TestEntityModel.prototype.get = function(filter, callback) { - callback(new AccessError('Error')); - }; + it('should send only entities the authenticated user can access', function(done) { + var expectedGroupIds = ['1', '2']; + var userPermissions = []; + expectedEntities = [{}]; + expectedPagination = {}; - var response = { - send: function(entities) { - assert.ok(false, 'Unexpected response'); + expectedGroupIds.forEach(function(groupId) { + userPermissions.push('get-group-' + groupId); + }); + + response = { + send: function(result) { + assert.strictEqual(result.entities, expectedEntities, 'Expected a list of entities'); + assert.strictEqual(result.pagination, expectedPagination, 'Expected pagination'); + done(); } }; - testContentController.getEntitiesAction({}, response, function(error) { - assert.equal(error.httpCode, 403); + request.user.id = '20'; + request.user.permissions = userPermissions; + + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + var userOperation = filter.getComparisonOperation(ResourceFilter.OPERATORS.IN, 'metadata.user'); + var groupOperation = filter.getComparisonOperation(ResourceFilter.OPERATORS.IN, 'metadata.groups'); + + assert.include(userOperation.value, request.user.id, 'Expected user id'); + assert.includeMembers(groupOperation.value, expectedGroupIds, 'Expected group id'); + assert.include(userOperation.value, anonymousId, 'Expected anonymous user id'); + callback(null, expectedEntities, expectedPagination); + }; + + testContentController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should be able to include only certain fields from the entities', function(done) { + var expectedIncludeFields = ['field1', 'field2']; + + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.deepEqual(fields.include, expectedIncludeFields, 'Wrong fields'); done(); + }; + + request.query.include = expectedIncludeFields; + + testContentController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); }); }); - it('should send an HTTP server error if model return an error', function(done) { - TestEntityModel.prototype.get = function(filter, callback) { - callback(new Error('Error')); + it('should be able to exclude only certain fields from the entities', function(done) { + var expectedExcludeFields = ['field1', 'field2']; + + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.deepEqual(fields.exclude, expectedExcludeFields); + done(); }; - var response = { - send: function(entities) { - assert.ok(false, 'Unexpected response'); - } + request.query.exclude = expectedExcludeFields; + + testContentController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should be able to set the limit number of entities by page', function(done) { + var expectedLimit = 42; + + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(limit, expectedLimit, 'Wrong limit'); + done(); }; - testContentController.getEntitiesAction({}, response, function(error) { - assert.equal(error.httpCode, 500); + request.query.limit = expectedLimit; + + testContentController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should be able to set the expected page of entities', function(done) { + var expectedPage = 42; + + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(page, expectedPage, 'Wrong page'); done(); + }; + + request.query.page = expectedPage; + + testContentController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); }); }); - }); + it('should be able to sort the list of entities by a particular field', function(done) { + var expectedSortField = 'field'; + var expectedSortOrder = 'asc'; - // getEntityAction method - describe('getEntityAction', function() { + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(sort[expectedSortField], expectedSortOrder, 'Wrong sort'); + done(); + }; - it('should send the entity returned by the model', function(done) { - var expectedEntity = {}; - TestEntityModel.prototype.getOne = function(id, filter, callback) { - callback(null, expectedEntity); + request.query.sortBy = expectedSortField; + request.query.sortOrder = expectedSortOrder; + + testContentController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should set default sort order to "desc"', function(done) { + var expectedSortField = 'field'; + + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(sort[expectedSortField], 'desc', 'Wrong sort'); + done(); }; - var response = { - send: function(entity) { - assert.strictEqual(entity.entity, expectedEntity, 'Expected entity'); - done(); - } + request.query.sortBy = expectedSortField; + + testContentController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should set default limit to 10 if not specified', function(done) { + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(limit, 10, 'Wrong limit'); + done(); }; - testContentController.getEntityAction({params: {id: 1}}, response, function(error) { + testContentController.getEntitiesAction(request, response, function(error) { assert.ok(false, 'Unexpected error : ' + error.message); }); }); - it('should send an HTTP forbidden error if model return an access error', function(done) { - TestEntityModel.prototype.getOne = function(id, filter, callback) { - callback(new AccessError('Error')); + it('should set default page to 0 if not specified', function(done) { + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(page, 0, 'Wrong page'); + done(); }; - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); - } + testContentController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should send an HTTP wrong parameters if include is not an Array of Strings', function(done) { + var wrongValues = [{}]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - testContentController.getEntityAction({params: {id: 1}}, response, function(error) { - assert.equal(error.httpCode, 403); - done(); + wrongValues.forEach(function(wrongValue) { + request.query.include = wrongValue; + testContentController.getEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITIES_WRONG_PARAMETERS); + }); + }); + + done(); + }); + + it('should send an HTTP wrong parameters if exclude is not an Array of Strings', function(done) { + var wrongValues = [{}]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + + wrongValues.forEach(function(wrongValue) { + request.query.exclude = wrongValue; + testContentController.getEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITIES_WRONG_PARAMETERS); + }); + }); + + done(); + }); + + it('should send an HTTP wrong parameters if limit is lesser than equal 0', function(done) { + var wrongValues = [-42, 0]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + wrongValues.forEach(function(wrongValue) { + request.query.limit = wrongValue; + testContentController.getEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITIES_WRONG_PARAMETERS); + }); + }); + + done(); + }); + + it('should send an HTTP wrong parameters if page is lesser than 0', function(done) { + var wrongValues = [-42]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + wrongValues.forEach(function(wrongValue) { + request.query.page = wrongValue; + testContentController.getEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITIES_WRONG_PARAMETERS); + }); }); + + done(); + }); + + it('should send an HTTP wrong parameters if sortOrder is different from "asc" or "desc"', function(done) { + var wrongValues = ['Something else']; + + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + + wrongValues.forEach(function(wrongValue) { + request.query.sortOrder = wrongValue; + testContentController.getEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITIES_WRONG_PARAMETERS); + }); + }); + + done(); }); - it('should send an HTTP server error if model return an error', function(done) { - TestEntityModel.prototype.getOne = function(id, filter, callback) { + it('should send an HTTP server error if an error occured while fetching entities', function(done) { + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { callback(new Error('Error')); }; var response = { - send: function(entity) { + send: function(entities) { assert.ok(false, 'Unexpected response'); } }; - testContentController.getEntityAction({params: {id: 1}}, response, function(error) { - assert.equal(error.httpCode, 500); + testContentController.getEntitiesAction(request, response, function(error) { + assert.equal(error, httpErrors.GET_ENTITIES_ERROR, 'Wrong error'); done(); }); }); - it('should send an HTTP missing parameter error if id is not specified', function(done) { - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); + }); + + describe('getEntityAction', function() { + + it('should send the entity corresponding to the given id', function(done) { + var expectedId = 'id'; + expectedEntities = [{ + metadata: { + user: request.user.id } + }]; + + response.send = function(result) { + assert.strictEqual(result.entity, expectedEntities[0], 'Wrong entity'); + done(); }; - testContentController.getEntityAction({params: {}}, response, function(error) { - assert.equal(error.httpCode, 400); + request.params.id = expectedId; + + testContentController.getEntityAction(request, response, function(error, entity) { + assert.isNull(error, 'Unexpected error'); + }); + }); + + it('should send the entity if user is the owner of the entity', function(done) { + var expectedId = '42'; + expectedEntities = [{ + id: expectedId, + metadata: { + user: request.user.id + } + }]; + + response.send = function(result) { + assert.strictEqual(result.entity, expectedEntities[0], 'Wrong entity'); done(); + }; + + request.params.id = expectedId; + + testContentController.getEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); }); }); - it('should send an HTTP not found error if entity is not found', function(done) { - TestEntityModel.prototype.getOne = function(id, filter, callback) { - callback(null); + it('should send the entity if entity belongs to the anonymous user', function(done) { + var expectedId = '42'; + expectedEntities = [{ + id: expectedId, + metadata: { + user: anonymousId + } + }]; + + response.send = function(result) { + assert.strictEqual(result.entity, expectedEntities[0], 'Wrong entity'); + done(); }; - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); + request.params.id = expectedId; + + testContentController.getEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should send the entity if user has read privilege upon one of the entity groups', function(done) { + var expectedId = '42'; + var expectedGroup = '43'; + expectedEntities = [{ + id: expectedId, + metadata: { + user: 'Something else', + groups: [expectedGroup] + } + }]; + + response.send = function(result) { + assert.strictEqual(result.entity, expectedEntities[0], 'Wrong entity'); + done(); + }; + + request.params.id = expectedId; + request.user.permissions = ['get-group-' + expectedGroup]; + + testContentController.getEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should send the entity if user is the super administrator', function(done) { + var expectedId = '42'; + expectedEntities = [{ + id: expectedId, + metadata: { + user: 'Something else' } + }]; + + response.send = function(result) { + assert.strictEqual(result.entity, expectedEntities[0], 'Wrong entity'); + done(); }; - testContentController.getEntityAction({params: {id: 1}}, response, function(error) { - assert.equal(error.httpCode, 404); + request.params.id = expectedId; + request.user.id = superAdminId; + + testContentController.getEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should be able to include only certain fields from the entity', function(done) { + var expectedInclude = ['field1', 'field2']; + + ProviderMock.getOne = function(filter, fields, callback) { + assert.deepEqual(fields.include, expectedInclude.concat(['metadata']), 'Wrong include'); + assert.isUndefined(fields.exclude, 'Unexpected exclude'); done(); + }; + + request.params.id = '42'; + request.query.include = expectedInclude; + + testContentController.getEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); }); }); - }); + it('should send an HTTP missing parameters if no id specified', function(done) { + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; - // updateEntityAction method - describe('updateEntityAction', function() { + testContentController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_MISSING_PARAMETERS, 'Wrong error'); + done(); + }); + }); - it('should send a status "ok" if entity has been updated', function(done) { - TestEntityModel.prototype.update = function(id, data, callback) { - callback(null, 1); + it('should send an HTTP wrong parameters if include is not an Array of Strings', function(done) { + var wrongValues = [{}]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - var response = { - send: function(res) { - assert.equal(res.status, 'ok'); - done(); - } + request.params.id = '42'; + + wrongValues.forEach(function(wrongValue) { + request.query.include = wrongValue; + testContentController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_WRONG_PARAMETERS, 'Wrong error'); + }); + }); + + done(); + }); + + it('should send an HTTP wrong parameters if exclude is not an Array of Strings', function(done) { + var wrongValues = [{}]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - testContentController.updateEntityAction({params: {id: 1}, body: {}}, response, function(error) { - assert.ok(false, 'Unexpected error : ' + error.message); + request.params.id = '42'; + + wrongValues.forEach(function(wrongValue) { + request.query.exclude = wrongValue; + testContentController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_WRONG_PARAMETERS, 'Wrong error'); + }); }); + + done(); }); - it('should send an HTTP forbidden error if model return an access error', function(done) { - TestEntityModel.prototype.update = function(id, data, callback) { - callback(new AccessError('Error')); + it('should send an HTTP server error if fetching entity failed', function(done) { + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - var response = { - send: function(res) { - assert.ok(false, 'Unexpected response'); - } + ProviderMock.getOne = function(filter, fields, callback) { + callback(new Error('Error')); }; - testContentController.updateEntityAction({params: {id: 1}, body: {}}, response, function(error) { - assert.equal(error.httpCode, 403); + request.params.id = '42'; + + testContentController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_ERROR, 'Wrong error'); done(); }); }); - it('should send an HTTP server error if model return an error', function(done) { - TestEntityModel.prototype.update = function(id, data, callback) { - callback(new Error('Error')); + it('should send an HTTP not found error if no entity found', function(done) { + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - var response = { - send: function(res) { - assert.ok(false, 'Unexpected response'); - } + ProviderMock.getOne = function(filter, fields, callback) { + callback(null); }; - testContentController.updateEntityAction({params: {id: 1}, body: {}}, response, function(error) { - assert.equal(error.httpCode, 500); + request.params.id = '42'; + + testContentController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_NOT_FOUND, 'Wrong error'); done(); }); }); - it('should send an HTTP missing parameter error if id is not specified', function(done) { - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); + it('should send an HTTP forbidden error if user has not enough privileges to get it', function(done) { + var expectedId = '42'; + expectedEntities = [ + { + id: expectedId, + metadata: { + user: 'Something else' + } } + ]; + + response.send = function(result) { + assert.ok(false, 'Unexpected response'); }; - testContentController.updateEntityAction({params: {}, body: {}}, response, function(error) { - assert.equal(error.httpCode, 400); + request.params.id = expectedId; + + testContentController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_FORBIDDEN, 'Wrong error'); done(); }); }); - it('should send an HTTP missing parameter error if body is empty', function(done) { - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); - } - }; + it('should not be able to exclude "metadata" property from response', function(done) { + var expectedExclude = ['field1', 'metadata']; - testContentController.updateEntityAction({params: {id: 1}}, response, function(error) { - assert.equal(error.httpCode, 400); + ProviderMock.getOne = function(filter, fields, callback) { + assert.notInclude(fields.exclude, 'metadata', 'Unexpected "metadata"'); done(); + }; + + request.params.id = '42'; + request.query.exclude = expectedExclude; + + testContentController.getEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); }); }); }); - // addEntityAction method - describe('addEntityAction', function() { + describe('updateEntityAction', function() { - it('should send the added entity', function(done) { - var expectedEntity = {}; - TestEntityModel.prototype.add = function(data, callback) { - callback(null, 1, expectedEntity); + it('should update entity and send operation result', function(done) { + var expectedData = { + field1: 'value1', + field2: 'value2' }; - - var response = { - send: function(entity) { - assert.strictEqual(entity.entity, expectedEntity); - done(); + expectedEntities = [ + { + id: '42', + metadata: { + user: request.user.id + } } + ]; + + response.send = function(result) { + assert.equal(result.total, 1, 'Wrong total'); + done(); }; - testContentController.addEntityAction({body: {}}, response, function(error) { - assert.ok(false, 'Unexpected error : ' + error.message); + ProviderMock.updateOne = function(filter, data, callback) { + assert.equal( + filter.getComparisonOperation(ResourceFilter.OPERATORS.EQUAL, 'id').value, + expectedEntities[0].id, + 'Wrong id' + ); + assert.deepEqual(data, expectedData, 'Wrong data'); + callback(null, 1); + }; + + request.params.id = expectedEntities[0].id; + request.body = expectedData; + + testContentController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); }); }); - it('should send an HTTP forbidden error if model return an access error', function(done) { - TestEntityModel.prototype.add = function(data, callback) { - callback(new AccessError('Error')); + it('should update entity if user has update privileges on one of the entity group', function(done) { + var expectedGroup = '42'; + expectedEntities = [ + { + id: '42', + metadata: { + user: 'Something else than actual user id', + groups: [expectedGroup] + } + } + ]; + + response.send = function(result) { + done(); }; - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); - } + ProviderMock.updateOne = function(filter, data, callback) { + callback(null, 1); }; - testContentController.addEntityAction({body: {}}, response, function(error) { - assert.equal(error.httpCode, 403); - done(); + request.params.id = expectedEntities[0].id; + request.body = {}; + request.user.permissions = ['update-group-' + expectedGroup]; + + testContentController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); }); }); - it('should send an HTTP server error if model return an error', function(done) { - TestEntityModel.prototype.add = function(data, callback) { - callback(new Error('Error')); + it('should update entity if entity belongs to the anonymous user', function(done) { + expectedEntities = [ + { + id: '42', + metadata: { + user: anonymousId + } + } + ]; + + response.send = function(result) { + done(); }; - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); - } + ProviderMock.updateOne = function(filter, data, callback) { + callback(null, 1); }; - testContentController.addEntityAction({body: {}}, response, function(error) { - assert.equal(error.httpCode, 500); + request.params.id = expectedEntities[0].id; + request.body = {}; + + testContentController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should update entity if user is the super administrator', function(done) { + expectedEntities = [ + { + id: '42', + metadata: { + user: 'Something else' + } + } + ]; + + response.send = function(result) { done(); + }; + + ProviderMock.updateOne = function(filter, data, callback) { + callback(null, 1); + }; + + request.params.id = expectedEntities[0].id; + request.body = {}; + request.user.id = superAdminId; + + testContentController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); }); }); - it('should send an HTTP missing parameter error if body is empty', function(done) { - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); + it('should update entity if user is a manager', function(done) { + expectedEntities = [ + { + id: '42', + metadata: { + user: 'Something else' + } } + ]; + + testContentController.isUserManager = function() { + return true; }; - testContentController.addEntityAction({}, response, function(error) { - assert.equal(error.httpCode, 400); + response.send = function(result) { done(); - }); + }; + + ProviderMock.updateOne = function(filter, data, callback) { + callback(null, 1); + }; + + request.params.id = expectedEntities[0].id; + request.body = {}; + + testContentController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should be able to update entity groups', function(done) { + var expectedGroupIds = ['42', '43']; + expectedEntities = [ + { + id: '42', + metadata: { + user: 'Something else' + } + } + ]; + + response.send = function(result) { + done(); + }; + + ProviderMock.updateOne = function(filter, data, callback) { + assert.deepEqual(data['metadata.groups'], expectedGroupIds, 'Wrong groups'); + callback(null, 1); + }; + + request.params.id = expectedEntities[0].id; + request.body = { + groups: expectedGroupIds + }; + request.user.id = superAdminId; + + testContentController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should be able to update entity owner as the owner', function(done) { + var expectedOwner = '42'; + expectedEntities = [ + { + id: '42', + metadata: { + user: request.user.id + } + } + ]; + + response.send = function(result) { + done(); + }; + + ProviderMock.updateOne = function(filter, data, callback) { + assert.deepEqual(data['metadata.user'], expectedOwner, 'Wrong owner'); + callback(null, 1); + }; + + request.params.id = expectedEntities[0].id; + request.body = { + user: expectedOwner + }; + + testContentController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should be able to update entity owner as the super administrator', function(done) { + var expectedOwner = '42'; + expectedEntities = [ + { + id: '42', + metadata: { + user: request.user.id + } + } + ]; + + response.send = function(result) { + done(); + }; + + ProviderMock.updateOne = function(filter, data, callback) { + assert.deepEqual(data['metadata.user'], expectedOwner, 'Wrong owner'); + callback(null, 1); + }; + + request.params.id = expectedEntities[0].id; + request.body = { + user: expectedOwner + }; + request.user.id = superAdminId; + + testContentController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should be able to update entity owner as a manager', function(done) { + var expectedOwner = 'Something else'; + expectedEntities = [ + { + id: '42', + metadata: { + user: '43' + } + } + ]; + + testContentController.isUserManager = function() { + return true; + }; + + response.send = function(result) { + done(); + }; + + ProviderMock.updateOne = function(filter, data, callback) { + assert.deepEqual(data['metadata.user'], expectedOwner, 'Wrong owner'); + callback(null, 1); + }; + + request.params.id = expectedEntities[0].id; + request.body = { + user: expectedOwner + }; + + testContentController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should not be able to update entity owner if not the super administrator nor the owner', function(done) { + expectedEntities = [ + { + id: '42', + metadata: { + user: anonymousId + } + } + ]; + + response.send = function(result) { + done(); + }; + + ProviderMock.updateOne = function(filter, data, callback) { + assert.isUndefined(data['user'], 'Unexpected user update'); + callback(null, 1); + }; + + request.params.id = expectedEntities[0].id; + request.body = { + user: 'Something else' + }; + + testContentController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should send an HTTP missing error if id parameter is not specified', function(done) { + var expectedData = {}; + + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + request.body = expectedData; + + testContentController.updateEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.UPDATE_ENTITY_MISSING_PARAMETERS, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP missing error if body is not specified', function(done) { + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + request.params.id = '42'; + + testContentController.updateEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.UPDATE_ENTITY_MISSING_PARAMETERS, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP server error if updating entity failed', function(done) { + expectedEntities = [ + { + id: '42', + metadata: { + user: anonymousId + } + } + ]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + ProviderMock.updateOne = function(filter, data, callback) { + callback(new Error('Error')); + }; + + request.params.id = expectedEntities[0].id; + request.body = {}; + + testContentController.updateEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.UPDATE_ENTITY_ERROR, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP forbidden error if user has not enough privileges to update entity', function(done) { + expectedEntities = [ + { + id: '42', + field1: 'value1', + metadata: { + user: 'Something else' + } + } + ]; + + response.send = function(result) { + assert.ok(false, 'Unexpected response'); + }; + + request.params.id = expectedEntities[0].id; + request.body = { + field1: 'New value' + }; + + testContentController.updateEntityAction(request, response, function(error) { + ProviderMock.updateOne.should.have.been.called.exactly(0); + assert.strictEqual(error, httpErrors.UPDATE_ENTITY_FORBIDDEN, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP wrong parameters error if groups is not an array of Strings', function(done) { + var wrongValues = [{}]; + + request.params.id = '42'; + wrongValues.forEach(function(wrongValue) { + request.body = { + groups: wrongValue + }; + + testContentController.updateEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.UPDATE_ENTITY_WRONG_PARAMETERS, 'Wrong error'); + done(); + }); + }); + }); + + }); + + describe('addEntitiesAction', function() { + + it('should add entities and send operation result', function(done) { + var expectedEntities = [{}]; + + response.send = function(result) { + assert.strictEqual(result.entities, expectedEntities, 'Wrong entities'); + assert.equal(result.total, expectedEntities.length, 'Wrong status'); + done(); + }; + + ProviderMock.add = function(entities, callback) { + for (var i = 0; i < entities.length; i++) + assert.strictEqual(entities[i], expectedEntities[i], 'Wrong entities ' + i); + + callback(null, expectedEntities.length, expectedEntities); + }; + + request.body = expectedEntities; + + testContentController.addEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should add metadata to the new entities', function(done) { + var expectedGroups = ['42']; + var expectedEntities = [{ + groups: expectedGroups + }]; + + response.send = function(result) { + done(); + }; + + ProviderMock.add = function(entities, callback) { + for (var i = 0; i < entities.length; i++) { + assert.strictEqual(entities[i].metadata.user, request.user.id, 'Wrong user for entity ' + i); + assert.deepEqual(entities[i].metadata.groups, expectedGroups, 'Wrong groups for entity ' + i); + } + + callback(null); + }; + + request.body = expectedEntities; + + testContentController.addEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should send an HTTP missing parameters error if body is not specified', function(done) { + response.send = function(result) { + assert.ok(false, 'Unexpected response'); + }; + + testContentController.addEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.ADD_ENTITIES_MISSING_PARAMETERS, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP wrong parameters error if body is not an array of objects', function(done) { + var wrongValues = [{}, 'String', true, 42, []]; + + response.send = function(result) { + assert.ok(false, 'Unexpected response'); + }; + + wrongValues.forEach(function(wrongValue) { + request.body = wrongValue; + testContentController.addEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.ADD_ENTITIES_WRONG_PARAMETERS, 'Wrong error'); + }); + }); + + done(); + }); + + it('should send an HTTP server error if adding entities failed', function(done) { + response.send = function(result) { + assert.ok(false, 'Unexpected response'); + }; + + ProviderMock.add = function(entities, callback) { + callback(new Error('Error')); + }; + + request.body = [{}]; + + testContentController.addEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.ADD_ENTITIES_ERROR, 'Wrong error'); + done(); + }); + + }); + + }); + + describe('removeEntitiesAction', function() { + + it('should remove entities and send operation result', function(done) { + var expectedIds = ['41', '42']; + expectedIds.forEach(function(expectedId) { + expectedEntities.push({ + id: expectedId, + metadata: { + user: request.user.id + } + }); + }); + + response.send = function(result) { + assert.equal(result.total, expectedIds.length, 'Wrong total'); + done(); + }; + + ProviderMock.remove = function(filter, callback) { + assert.equal(filter.operations[0].type, ResourceFilter.OPERATORS.IN, 'Wrong operation type'); + assert.equal(filter.operations[0].field, 'id', 'Wrong operation field'); + assert.deepEqual(filter.operations[0].value, expectedIds, 'Wrong operation value'); + callback(null, expectedIds.length); + }; + + request.params.id = expectedIds.join(','); + + testContentController.removeEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should remove entities if user is the super administrator', function(done) { + var expectedId = '42'; + expectedEntities.push({ + id: expectedId, + metadata: { + user: 'Something else' + } + }); + + response.send = function(result) { + done(); + }; + + ProviderMock.remove = function(filter, callback) { + assert.include(filter.operations[0].value, expectedId, 'Wrong operation value'); + callback(null, expectedEntities.length); + }; + + request.params.id = expectedId; + request.user.id = superAdminId; + + testContentController.removeEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should remove entities if entities belong to the anonymous user', function(done) { + var expectedId = '42'; + expectedEntities.push({ + id: expectedId, + metadata: { + user: anonymousId + } + }); + + response.send = function(result) { + done(); + }; + + ProviderMock.remove = function(filter, callback) { + assert.include(filter.operations[0].value, expectedId, 'Wrong operation value'); + callback(null, expectedEntities.length); + }; + + request.params.id = expectedId; + + testContentController.removeEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should remove entities if user has delete privilege on one of entities groups', function(done) { + var expectedId = '42'; + var expectedGroup = '43'; + expectedEntities.push({ + id: expectedId, + metadata: { + user: 'Something else', + groups: [expectedGroup] + } + }); + + response.send = function(result) { + done(); + }; + + ProviderMock.remove = function(filter, callback) { + assert.include(filter.operations[0].value, expectedId, 'Wrong operation value'); + callback(null, expectedEntities.length); + }; + + request.params.id = expectedId; + request.user.permissions = ['delete-group-' + expectedGroup]; + + testContentController.removeEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should send an HTTP missing parameters error if id is not specified', function(done) { + response.send = function(result) { + assert.ok(false, 'Unexpected error'); + }; + + testContentController.removeEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.REMOVE_ENTITIES_MISSING_PARAMETERS, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP server error if removing entities failed', function(done) { + var expectedId = '42'; + expectedEntities.push({ + id: expectedId, + metadata: { + user: request.user.id + } + }); + + response.send = function(result) { + assert.ok(false, 'Unexpected error'); + }; + + ProviderMock.remove = function(filter, callback) { + callback(new Error('Error')); + }; + + request.params.id = expectedId; + + testContentController.removeEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.REMOVE_ENTITIES_ERROR, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP server error if removing entities partially failed', function(done) { + var expectedIds = ['41', '42']; + expectedIds.forEach(function(expectedId) { + expectedEntities.push({ + id: expectedId, + metadata: { + user: request.user.id + } + }); + }); + + response.send = function(result) { + assert.ok(false, 'Unexpected error'); + }; + + ProviderMock.remove = function(filter, callback) { + callback(null, expectedIds.length - 1); + }; + + request.params.id = expectedIds.join(','); + + testContentController.removeEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.REMOVE_ENTITIES_ERROR, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP forbidden if user has not enough privilege to delete entities', function(done) { + var expectedId = '42'; + expectedEntities.push({ + id: expectedId, + metadata: { + user: 'Something else' + } + }); + + response.send = function(result) { + assert.ok(false, 'Unexpected response'); + }; + + request.params.id = expectedId; + + testContentController.removeEntitiesAction(request, response, function(error) { + ProviderMock.remove.should.have.been.called.exactly(0); + assert.strictEqual(error, httpErrors.REMOVE_ENTITIES_FORBIDDEN, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP server error if some entities could not be removed', function(done) { + var expectedId = '42'; + expectedEntities.push({ + id: expectedId, + metadata: { + user: request.user.id + } + }); + + response.send = function(result) { + assert.ok(false, 'Unexpected response'); + }; + + ProviderMock.remove = function(filter, callback) { + callback(null, 0); + }; + + request.params.id = expectedId; + + testContentController.removeEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.REMOVE_ENTITIES_ERROR, 'Wrong error'); + done(); + }); + }); + + }); + + describe('addAccessFilter', function() { + + it('should add user information to a provider filter', function() { + var filter = new ResourceFilter(); + var expectedId = '42'; + var expectedGroupIds = ['43', '44']; + var permissions = expectedGroupIds.map(function(groupId) { + return 'get-group-' + groupId; + }); + + var expectedFilter = testContentController.addAccessFilter(filter, { + id: expectedId, + permissions: permissions + }); + + var userOperation = expectedFilter.getComparisonOperation(ResourceFilter.OPERATORS.IN, 'metadata.user'); + var groupsOperation = expectedFilter.getComparisonOperation(ResourceFilter.OPERATORS.IN, 'metadata.groups'); + + assert.deepEqual(userOperation.value, [expectedId, anonymousId], 'Wrong user filter'); + assert.deepEqual(groupsOperation.value, expectedGroupIds, 'Wrong groups filter'); + }); + + it('should create a new ResourceFilter if none specified', function() { + var expectedId = '42'; + + var expectedFilter = testContentController.addAccessFilter(null, { + id: expectedId + }); + + var userOperation = expectedFilter.getComparisonOperation(ResourceFilter.OPERATORS.IN, 'metadata.user'); + + assert.deepEqual(userOperation.value, [expectedId, anonymousId], 'Wrong user filter'); + }); + + it('should return the filter as is if user is not specified', function() { + var filter = testContentController.addAccessFilter(new ResourceFilter()); + assert.isEmpty(filter.operations, 'Unexpected rules'); + }); + + it('should return the filter as is if user is the super administrator', function() { + var filter = testContentController.addAccessFilter(new ResourceFilter(), { + id: superAdminId + }); + assert.isEmpty(filter.operations, 'Unexpected rules'); + }); + + it('should return the filter as is if user is a manager', function() { + testContentController.isUserManager = function() { + return true; + }; + + var filter = testContentController.addAccessFilter(new ResourceFilter(), { + id: '42' + }); + assert.isEmpty(filter.operations, 'Unexpected rules'); + }); + + }); + + describe('isUserAdmin', function() { + + it('should return true if user is the super administrator', function() { + assert.ok(testContentController.isUserAdmin({ + id: superAdminId + }), 'Expected user to be the super administrator'); + }); + + it('should return false if user is not the super administrator', function() { + assert.notOk(testContentController.isUserAdmin({ + id: '42' + }), 'Expected user not to be the super administrator'); + }); + + it('should return false if user is not specified', function() { + assert.notOk(testContentController.isUserAdmin(), 'Expected user not to be the super administrator'); + }); + + }); + + describe('isUserAnonymous', function() { + + it('should return true if user is anonymous', function() { + assert.ok(testContentController.isUserAnonymous({ + id: anonymousId + }), 'Expected user to be anonymous'); + }); + + it('should return false if user is not anonymous', function() { + assert.notOk(testContentController.isUserAnonymous({ + id: '42' + }), 'Expected user not to be anonymous'); + }); + + it('should return false if user is not specified', function() { + assert.notOk(testContentController.isUserAnonymous(), 'Expected user not to be anonymous'); + }); + + }); + + describe('isUserOwner', function() { + + it('should return true if user owns the entity', function() { + var expectedId = '42'; + assert.ok(testContentController.isUserOwner({ + metadata: { + user: expectedId + } + }, { + id: expectedId + }), 'Expected user to be the owner'); + }); + + it('should return false if user is not specified', function() { + assert.notOk(testContentController.isUserOwner({ + metadata: { + user: '42' + } + }), 'Expected user not to be the owner'); + }); + + it('should return false if entity has no associated metadata', function() { + assert.notOk(testContentController.isUserOwner({}, { + id: '42' + }), 'Expected user not to be the owner'); + }); + + }); + + describe('isUserAuthorized', function() { + + it('should return true if the user is the super administrator', function() { + assert.ok(testContentController.isUserAuthorized({ + id: superAdminId + }, { + metadata: { + user: 'Something else' + } + }, ContentController.OPERATIONS.READ), 'Expected user to be authorized'); + }); + + it('should return true if the user is a manager', function() { + testContentController.isUserManager = function() { + return true; + }; + + assert.ok(testContentController.isUserAuthorized({ + id: '42' + }, { + metadata: { + user: 'Something else' + } + }, ContentController.OPERATIONS.READ), 'Expected user to be authorized'); + }); + + it('should return true if user is the owner', function() { + var expectedId = '42'; + assert.ok(testContentController.isUserAuthorized({ + id: expectedId + }, { + metadata: { + user: expectedId + } + }, ContentController.OPERATIONS.READ), 'Expected user to be authorized'); + }); + + it('should return true if entity belongs to the anonymous user', function() { + assert.ok(testContentController.isUserAuthorized(null, { + metadata: { + user: anonymousId + } + }, ContentController.OPERATIONS.READ), 'Expected user to be authorized'); + }); + + it('should return true if user has operation privilege on one of the entity groups', function() { + var expectedGroup = '42'; + var operations = [ + ContentController.OPERATIONS.READ, + ContentController.OPERATIONS.UPDATE, + ContentController.OPERATIONS.DELETE + ]; + + operations.forEach(function(operation) { + assert.ok(testContentController.isUserAuthorized({ + id: '42', + permissions: [operation + '-group-' + expectedGroup] + }, { + metadata: { + user: 'Something else', + groups: [expectedGroup] + } + }, operation), 'Expected user to be authorized on operation "' + operation + '"'); + }); + }); + + it('should return false if user does not have enough privileges', function() { + var operations = [ + ContentController.OPERATIONS.READ, + ContentController.OPERATIONS.UPDATE, + ContentController.OPERATIONS.DELETE + ]; + + operations.forEach(function(operation) { + assert.notOk(testContentController.isUserAuthorized({ + id: '42' + }, { + metadata: { + user: 'Something else' + } + }, operation), 'Expected user to be unauthorized on operation "' + operation + '"'); + }); + }); + + }); + + describe('removeMetatadaFromFields', function() { + + it('should add "metadata" into include fields', function() { + var includedFields = ['field1', 'field2']; + var fields = testContentController.removeMetatadaFromFields({ + include: includedFields + }); + + assert.include(fields.include, 'metadata', 'Expected "metadata" field'); + }); + + it('should remove "metadata" from exclude fields', function() { + var excludedFields = ['field1', 'metadata', 'field2']; + var fields = testContentController.removeMetatadaFromFields({ + exclude: excludedFields + }); + + assert.notInclude(fields.exclude, 'metadata', 'Unexpected "metadata" field'); + }); + + it('should not create include or exclude fields if not specified', function() { + var fields = testContentController.removeMetatadaFromFields({}); + + assert.notProperty(fields, 'include', 'Unexpected "include" property'); + assert.notProperty(fields, 'exclude', 'Unexpected "exclude" property'); }); }); diff --git a/tests/server/controllers/EntityController.js b/tests/server/controllers/EntityController.js index cb2fd97..a75519e 100644 --- a/tests/server/controllers/EntityController.js +++ b/tests/server/controllers/EntityController.js @@ -3,357 +3,643 @@ var util = require('util'); var assert = require('chai').assert; var EntityController = process.requireApi('lib/controllers/EntityController.js'); -var EntityModel = process.requireApi('lib/models/EntityModel.js'); -var EntityProvider = process.requireApi('lib/providers/EntityProvider.js'); -var Database = process.requireApi('lib/database/Database.js'); +var ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js'); +var httpErrors = process.requireApi('lib/controllers/httpErrors.js'); -// EntityController.js describe('EntityController', function() { - var TestEntityModel; - var TestEntityProvider; + var ProviderMock; var TestEntityController; var testEntityController; + var expectedEntities; + var expectedPagination; + var expectedCount = 42; + var response; + var request; - // Mocks + // Initiates mocks beforeEach(function() { - TestEntityModel = function(provider) { - TestEntityModel.super_.call(this, provider); + ProviderMock = { + get: function(filter, fields, limit, page, sort, callback) { + callback(null, expectedEntities, expectedPagination); + }, + getOne: function(filter, fields, callback) { + callback(null, expectedEntities[0]); + }, + update: function(filter, data, callback) { + callback(null, expectedCount); + }, + add: function(entities, callback) { + callback(null, expectedCount); + }, + remove: function(filter, callback) { + callback(null, expectedCount); + }, + removeField: function(field, filter, callback) { + callback(null, expectedCount); + } }; - TestEntityProvider = function(database) { - TestEntityProvider.super_.call(this, database, 'test_collection'); + response = { + send: function() {} }; - TestEntityController = function(ModelConstructor, ProviderConstructor) { - TestEntityController.super_.call(this, ModelConstructor, ProviderConstructor); + request = { + query: {}, + params: {} }; - TestEntityController.prototype.getModel = function() { - return new TestEntityModel(new TestEntityProvider(new Database({}))); + TestEntityController = function() { + TestEntityController.super_.call(this); + }; + + TestEntityController.prototype.getProvider = function() { + return ProviderMock; }; - util.inherits(TestEntityModel, EntityModel); - util.inherits(TestEntityProvider, EntityProvider); util.inherits(TestEntityController, EntityController); }); - // Prepare tests using mocks + // Initiates tests beforeEach(function() { testEntityController = new TestEntityController(); }); - // getEntitiesAction method describe('getEntitiesAction', function() { - it('should send the list of entities returned by the model', function(done) { - var expectedEntities = {}; - TestEntityModel.prototype.get = function(filter, callback) { - callback(null, expectedEntities); - }; + it('should send the list of entities with pagination', function(done) { + expectedEntities = [{}]; + expectedPagination = {}; - var response = { - send: function(entities) { - assert.strictEqual(entities.entities, expectedEntities, 'Expected a list of entities'); + response = { + send: function(result) { + assert.strictEqual(result.entities, expectedEntities, 'Expected a list of entities'); + assert.strictEqual(result.pagination, expectedPagination, 'Expected pagination'); done(); } }; - testEntityController.getEntitiesAction({}, response, function(error) { + testEntityController.getEntitiesAction(request, response, function(error) { assert.ok(false, 'Unexpected error : ' + error.message); }); }); - it('should send an HTTP server error if model return an error', function(done) { - TestEntityModel.prototype.get = function(filter, callback) { - callback(new Error('Error')); - }; + it('should be able to include only certain fields from the list of entities', function(done) { + var expectedInclude = ['field1', 'field2']; - var response = { - send: function(entities) { - assert.ok(false, 'Unexpected response'); - } + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.deepEqual(fields.include, expectedInclude, 'Wrong include'); + assert.isUndefined(fields.exclude, 'Unexpected exclude'); + done(); }; - testEntityController.getEntitiesAction({}, response, function(error) { - assert.equal(error.httpCode, 500); + request.query.include = expectedInclude; + + testEntityController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should be able to exclude only certain fields from the list of entities', function(done) { + var expectedExclude = ['field1', 'field2']; + + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.deepEqual(fields.exclude, expectedExclude, 'Wrong exclude'); + assert.isUndefined(fields.include, 'Unexpected include'); done(); + }; + + request.query.exclude = expectedExclude; + + testEntityController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); }); }); - }); + it('should be able to set the limit number of entities by page', function(done) { + var expectedLimit = 42; - // getEntityAction method - describe('getEntityAction', function() { + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(limit, expectedLimit, 'Wrong limit'); + done(); + }; + + request.query.limit = expectedLimit; - it('should send the entity returned by the model', function(done) { - var expectedEntity = {}; - TestEntityModel.prototype.getOne = function(id, filter, callback) { - callback(null, expectedEntity); + testEntityController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should be able to set the expected page of entities', function(done) { + var expectedPage = 42; + + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(page, expectedPage, 'Wrong page'); + done(); }; - var response = { - send: function(entity) { - assert.strictEqual(entity.entity, expectedEntity, 'Expected entity'); - done(); - } + request.query.page = expectedPage; + + testEntityController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should be able to sort the list of entities by a particular field', function(done) { + var expectedSortField = 'field'; + var expectedSortOrder = 'asc'; + + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(sort[expectedSortField], expectedSortOrder, 'Wrong sort'); + done(); }; - testEntityController.getEntityAction({params: {id: 1}}, response, function(error) { + request.query.sortBy = expectedSortField; + request.query.sortOrder = expectedSortOrder; + + testEntityController.getEntitiesAction(request, response, function(error) { assert.ok(false, 'Unexpected error : ' + error.message); }); }); - it('should send an HTTP server error if model return an error', function(done) { - TestEntityModel.prototype.getOne = function(id, filter, callback) { - callback(new Error('Error')); + it('should set default sort order to "desc"', function(done) { + var expectedSortField = 'field'; + + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(sort[expectedSortField], 'desc', 'Wrong sort'); + done(); }; - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); - } + request.query.sortBy = expectedSortField; + + testEntityController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should set default limit to 10 if not specified', function(done) { + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(limit, 10, 'Wrong limit'); + done(); }; - testEntityController.getEntityAction({params: {id: 1}}, response, function(error) { - assert.equal(error.httpCode, 500); + testEntityController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); + }); + }); + + it('should set default page to 0 if not specified', function(done) { + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + assert.equal(page, 0, 'Wrong page'); done(); + }; + + testEntityController.getEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error : ' + error.message); }); }); - it('should send an HTTP missing parameter error if id is not specified', function(done) { - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); - } + it('should send an HTTP wrong parameters if include is not an Array of Strings', function(done) { + var wrongValues = [{}]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - testEntityController.getEntityAction({params: {}}, response, function(error) { - assert.equal(error.httpCode, 400); - done(); + wrongValues.forEach(function(wrongValue) { + request.query.include = wrongValue; + testEntityController.getEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITIES_WRONG_PARAMETERS); + }); }); + + done(); }); - it('should send an HTTP not found error if entity is not found', function(done) { - TestEntityModel.prototype.getOne = function(id, filter, callback) { - callback(null); + it('should send an HTTP wrong parameters if exclude is not an Array of Strings', function(done) { + var wrongValues = [{}]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + wrongValues.forEach(function(wrongValue) { + request.query.exclude = wrongValue; + testEntityController.getEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITIES_WRONG_PARAMETERS); + }); + }); + + done(); + }); + + it('should send an HTTP wrong parameters if limit is lesser than equal 0', function(done) { + var wrongValues = [-42, 0]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + wrongValues.forEach(function(wrongValue) { + request.query.limit = wrongValue; + testEntityController.getEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITIES_WRONG_PARAMETERS); + }); + }); + + done(); + }); + + it('should send an HTTP wrong parameters if page is lesser than 0', function(done) { + var wrongValues = [-42]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + wrongValues.forEach(function(wrongValue) { + request.query.page = wrongValue; + testEntityController.getEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITIES_WRONG_PARAMETERS); + }); + }); + + done(); + }); + + it('should send an HTTP wrong parameters if sortOrder is different from "asc" or "desc"', function(done) { + var wrongValues = ['Something else']; + + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + wrongValues.forEach(function(wrongValue) { + request.query.sortOrder = wrongValue; + testEntityController.getEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITIES_WRONG_PARAMETERS); + }); + }); + + done(); + }); + + it('should send an HTTP server error if an error occured while fetching entities', function(done) { + ProviderMock.get = function(filter, fields, limit, page, sort, callback) { + callback(new Error('Error')); }; var response = { - send: function(entity) { + send: function(entities) { assert.ok(false, 'Unexpected response'); } }; - testEntityController.getEntityAction({params: {id: 1}}, response, function(error) { - assert.equal(error.httpCode, 404); + testEntityController.getEntitiesAction(request, response, function(error) { + assert.equal(error, httpErrors.GET_ENTITIES_ERROR, 'Wrong error'); done(); }); }); }); - // updateEntityAction method - describe('updateEntityAction', function() { + describe('getEntityAction', function() { - it('should send a status "ok" if entity has been updated', function(done) { - TestEntityModel.prototype.update = function(id, data, callback) { - callback(null, 1); + it('should send the entity corresponding to the given id', function(done) { + var expectedId = 'id'; + expectedEntities = [{}]; + + response.send = function(result) { + assert.strictEqual(result.entity, expectedEntities[0], 'Wrong entity'); + done(); }; - var response = { - send: function(res) { - assert.equal(res.status, 'ok'); - done(); - } + request.params.id = expectedId; + + testEntityController.getEntityAction(request, response, function(error, entity) { + assert.isNull(error, 'Unexpected error'); + }); + }); + + it('should be able to include only certain fields from the entity', function(done) { + var expectedInclude = ['field1', 'field2']; + + ProviderMock.getOne = function(filter, fields, callback) { + assert.deepEqual(fields.include, expectedInclude, 'Wrong include'); + assert.isUndefined(fields.exclude, 'Unexpected exclude'); + done(); }; - testEntityController.updateEntityAction({params: {id: 1}, body: {}}, response, function(error) { + request.params.id = '42'; + request.query.include = expectedInclude; + + testEntityController.getEntityAction(request, response, function(error) { assert.ok(false, 'Unexpected error : ' + error.message); }); }); - it('should send an HTTP server error if model return an error', function(done) { - TestEntityModel.prototype.update = function(id, data, callback) { - callback(new Error('Error')); + it('should send an HTTP missing parameters if no id specified', function(done) { + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - var response = { - send: function(res) { - assert.ok(false, 'Unexpected response'); - } + testEntityController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_MISSING_PARAMETERS); + done(); + }); + }); + + it('should send an HTTP wrong parameters if include is not an Array of Strings', function(done) { + var wrongValues = [{}]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - testEntityController.updateEntityAction({params: {id: 1}, body: {}}, response, function(error) { - assert.equal(error.httpCode, 500); - done(); + request.params.id = '42'; + + wrongValues.forEach(function(wrongValue) { + request.query.include = wrongValue; + testEntityController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_WRONG_PARAMETERS); + }); }); + + done(); }); - it('should send an HTTP missing parameter error if id is not specified', function(done) { - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); - } + it('should send an HTTP wrong parameters if exclude is not an Array of Strings', function(done) { + var wrongValues = [{}]; + + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - testEntityController.updateEntityAction({params: {}, body: {}}, response, function(error) { - assert.equal(error.httpCode, 400); + request.params.id = '42'; + + wrongValues.forEach(function(wrongValue) { + request.query.exclude = wrongValue; + testEntityController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_WRONG_PARAMETERS); + }); + }); + + done(); + }); + + it('should send an HTTP server error if fetching entity failed', function(done) { + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + ProviderMock.getOne = function(filter, fields, callback) { + callback(new Error('Error')); + }; + + request.params.id = '42'; + + testEntityController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_ERROR, 'Wrong error'); done(); }); }); - it('should send an HTTP missing parameter error if body is empty', function(done) { - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); - } + it('should send an HTTP not found error if no entity found', function(done) { + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + ProviderMock.getOne = function(filter, fields, callback) { + callback(null); }; - testEntityController.updateEntityAction({params: {id: 1}}, response, function(error) { - assert.equal(error.httpCode, 400); + request.params.id = '42'; + + testEntityController.getEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.GET_ENTITY_NOT_FOUND, 'Wrong error'); done(); }); }); }); - // addEntityAction method - describe('addEntityAction', function() { + describe('updateEntityAction', function() { + + it('should update entity and send operation result', function(done) { + var expectedData = {}; + var expectedId = '42'; - it('should send the added entity', function(done) { - var expectedEntity = {}; - TestEntityModel.prototype.add = function(data, callback) { - callback(null, 1, expectedEntity); + response.send = function(result) { + assert.equal(result.total, 1, 'Wrong total'); + done(); }; - var response = { - send: function(entity) { - assert.strictEqual(entity.entity, expectedEntity); - done(); - } + ProviderMock.updateOne = function(filter, data, callback) { + assert.equal( + filter.getComparisonOperation(ResourceFilter.OPERATORS.EQUAL, 'id').value, + expectedId, + 'Wrong id' + ); + assert.strictEqual(data, expectedData, 'Wrong data'); + callback(null, 1); }; - testEntityController.addEntityAction({body: {}}, response, function(error) { - assert.ok(false, 'Unexpected error : ' + error.message); + request.params.id = expectedId; + request.body = expectedData; + + testEntityController.updateEntityAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); }); }); - it('should send an HTTP server error if model return an error', function(done) { - TestEntityModel.prototype.add = function(data, callback) { - callback(new Error('Error')); + it('should send an HTTP missing error if id parameter is not specified', function(done) { + var expectedData = {}; + + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); - } + request.body = expectedData; + + testEntityController.updateEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.UPDATE_ENTITY_MISSING_PARAMETERS, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP missing error if body is not specified', function(done) { + response.send = function() { + assert.ok(false, 'Unexpected response'); }; - testEntityController.addEntityAction({body: {}}, response, function(error) { - assert.equal(error.httpCode, 500); + request.params.id = '42'; + + testEntityController.updateEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.UPDATE_ENTITY_MISSING_PARAMETERS, 'Wrong error'); done(); }); }); - it('should send an HTTP missing parameter error if body is empty', function(done) { - var response = { - send: function(entity) { - assert.ok(false, 'Unexpected response'); - } + it('should send an HTTP server error if updating entity failed', function(done) { + response.send = function() { + assert.ok(false, 'Unexpected response'); + }; + + ProviderMock.updateOne = function(filter, data, callback) { + callback(new Error('Error')); }; - testEntityController.addEntityAction({}, response, function(error) { - assert.equal(error.httpCode, 400); + request.params.id = '42'; + request.body = {}; + + testEntityController.updateEntityAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.UPDATE_ENTITY_ERROR, 'Wrong error'); done(); }); }); }); - // removeEntityAction method - describe('removeEntityAction', function() { + describe('addEntitiesAction', function() { - it('should send a status "ok" if the entity has been removed', function(done) { - TestEntityModel.prototype.remove = function(ids, callback) { - callback(null, 1); + it('should add entities and send operation result', function(done) { + var expectedEntities = [{}]; + + response.send = function(result) { + assert.strictEqual(result.entities, expectedEntities, 'Wrong entities'); + assert.equal(result.total, expectedEntities.length, 'Wrong total'); + done(); }; - var response = { - send: function(res) { - assert.equal(res.status, 'ok'); - done(); - } + ProviderMock.add = function(entities, callback) { + for (var i = 0; i < entities.length; i++) + assert.strictEqual(entities[i], expectedEntities[i], 'Wrong entities ' + i); + + callback(null, expectedEntities.length, expectedEntities); }; - testEntityController.removeEntityAction({params: {id: '42'}}, response, function(error) { - assert.ok(false, 'Unexpected error : ' + error.message); + request.body = expectedEntities; + + testEntityController.addEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); }); }); - it('should be able to ask model to remove a list of entities', function(done) { - var expectedIds = ['1', '2', '3']; - TestEntityModel.prototype.remove = function(ids, callback) { - assert.sameMembers(ids, expectedIds, 'Expected ids to be the same as the one specified in the request'); - callback(null, 3); + it('should send an HTTP missing parameters error if body is not specified', function(done) { + response.send = function(result) { + assert.ok(false, 'Unexpected response'); }; - var response = { - send: function(res) { - assert.equal(res.status, 'ok'); - done(); - } + testEntityController.addEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.ADD_ENTITIES_MISSING_PARAMETERS, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP wrong parameters error if body is not an array of objects', function(done) { + var wrongValues = [{}, 'String', true, 42, []]; + + response.send = function(result) { + assert.ok(false, 'Unexpected response'); }; - testEntityController.removeEntityAction({params: {id: expectedIds.join(',')}}, response, function(error) { - assert.ok(false, 'Unexpected error : ' + error.message); + wrongValues.forEach(function(wrongValue) { + request.body = wrongValue; + testEntityController.addEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.ADD_ENTITIES_WRONG_PARAMETERS, 'Wrong error'); + }); }); + + done(); }); - it('should send an HTTP server error if model has not removed the expected number of entities', function(done) { - var expectedIds = ['1', '2', '3']; - TestEntityModel.prototype.remove = function(ids, callback) { - assert.sameMembers(ids, expectedIds, 'Expected ids to be the same as the one specified in the request'); - callback(null, 2); + it('should send an HTTP server error if adding entities failed', function(done) { + response.send = function(result) { + assert.ok(false, 'Unexpected response'); }; - var response = { - send: function(res) { - assert.ok(false, 'Unexpected response'); - } + ProviderMock.add = function(entities, callback) { + callback(new Error('Error')); }; - testEntityController.removeEntityAction({params: {id: expectedIds.join(',')}}, response, function(error) { - assert.equal(error.httpCode, 500); + request.body = [{}]; + + testEntityController.addEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.ADD_ENTITIES_ERROR, 'Wrong error'); done(); }); + }); - it('should send an HTTP server error if model return an error', function(done) { - TestEntityModel.prototype.remove = function(ids, callback) { - callback(new Error('Error')); + }); + + describe('removeEntitiesAction', function() { + + it('should remove entities and send operation result', function(done) { + var expectedIds = ['41', '42']; + + response.send = function(result) { + assert.equal(result.total, expectedIds.length, 'Wrong total'); + done(); }; - var response = { - send: function(res) { - assert.ok(false, 'Unexpected response'); - } + ProviderMock.remove = function(filter, callback) { + assert.equal(filter.operations[0].type, ResourceFilter.OPERATORS.IN, 'Wrong operation type'); + assert.equal(filter.operations[0].field, 'id', 'Wrong operation field'); + assert.deepEqual(filter.operations[0].value, expectedIds, 'Wrong operation value'); + callback(null, expectedIds.length); }; - testEntityController.removeEntityAction({params: {id: '42'}}, response, function(error) { - assert.equal(error.httpCode, 500); + request.params.id = expectedIds.join(','); + + testEntityController.removeEntitiesAction(request, response, function(error) { + assert.ok(false, 'Unexpected error'); + }); + }); + + it('should send an HTTP missing parameters error if id is not specified', function(done) { + response.send = function(result) { + assert.ok(false, 'Unexpected error'); + }; + + testEntityController.removeEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.REMOVE_ENTITIES_MISSING_PARAMETERS, 'Wrong error'); done(); }); }); - it('should send an HTTP missing parameter error if id is not specified', function(done) { - var response = { - send: function(res) { - assert.ok(false, 'Unexpected response'); - } + it('should send an HTTP server error if removing entities failed', function(done) { + response.send = function(result) { + assert.ok(false, 'Unexpected error'); }; - testEntityController.removeEntityAction({params: {}}, response, function(error) { - assert.equal(error.httpCode, 400); + ProviderMock.remove = function(filter, callback) { + callback(new Error('Error')); + }; + + request.params.id = '42,43'; + + testEntityController.removeEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.REMOVE_ENTITIES_ERROR, 'Wrong error'); + done(); + }); + }); + + it('should send an HTTP server error if removing entities partially failed', function(done) { + var expectedIds = ['42', '43']; + + response.send = function(result) { + assert.ok(false, 'Unexpected error'); + }; + + ProviderMock.remove = function(filter, callback) { + callback(null, expectedIds.length - 1); + }; + + request.params.id = expectedIds.join(','); + + testEntityController.removeEntitiesAction(request, response, function(error) { + assert.strictEqual(error, httpErrors.REMOVE_ENTITIES_ERROR, 'Wrong error'); done(); }); }); diff --git a/tests/server/controllers/SocketController.js b/tests/server/controllers/SocketController.js index 3434e28..f3c4d4f 100644 --- a/tests/server/controllers/SocketController.js +++ b/tests/server/controllers/SocketController.js @@ -13,8 +13,8 @@ describe('SocketController', function() { // Mocks beforeEach(function() { - TestSocketController = function(ModelConstructor, ProviderConstructor) { - TestSocketController.super_.call(this, ModelConstructor, ProviderConstructor); + TestSocketController = function() { + TestSocketController.super_.call(this); }; util.inherits(TestSocketController, SocketController); diff --git a/tests/server/database/MongoDatabase.js b/tests/server/database/MongoDatabase.js deleted file mode 100644 index 119b55d..0000000 --- a/tests/server/database/MongoDatabase.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict'; - -var assert = require('chai').assert; -var MongoDatabase = process.requireApi('lib/database/mongodb/MongoDatabase.js'); - -// MongoDatabase.js -describe('MongoDatabase', function() { - - // properties - describe('properties', function() { - - it('should not be editable', function() { - var properties = ['seedlist', 'replicaSet']; - var database = new MongoDatabase({}); - - properties.forEach(function(property) { - assert.throws(function() { - database[property] = null; - }, null, null, 'Expected property "' + property + '" to be unalterable'); - }); - - }); - - }); - -}); diff --git a/tests/server/database/databaseFactory.js b/tests/server/database/databaseFactory.js deleted file mode 100644 index bdc7c8f..0000000 --- a/tests/server/database/databaseFactory.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict'; - -var assert = require('chai').assert; -var factory = process.requireApi('lib/database/factory.js'); -var MongoDatabase = process.requireApi('lib/database/mongodb/MongoDatabase.js'); - -// factory.js -describe('Database factory', function() { - - // get method - describe('get', function() { - - it('should be able to instanciate a MongoDatabase', function() { - var database = factory.get({ - type: 'mongodb' - }); - assert.ok(database instanceof MongoDatabase); - }); - - it('should throw a TypeError if unknown database type', function() { - assert.throws(function() { - factory.get({ - type: 'wrontType' - }); - }); - }); - - it('should throw a TypeError if no database configuration', function() { - assert.throws(function() { - factory.get(); - }); - }); - - }); - -}); diff --git a/tests/server/fileSystem/fileSystem.js b/tests/server/fileSystem/fileSystem.js index 74e5d27..9f6d802 100644 --- a/tests/server/fileSystem/fileSystem.js +++ b/tests/server/fileSystem/fileSystem.js @@ -8,6 +8,13 @@ var fileSystem = process.requireApi('lib/fileSystem.js'); // fileSystem.js describe('fileSystem', function() { + // Create tmp directory before each test + beforeEach(function(done) { + fileSystem.mkdir(path.join(__dirname, '/tmp'), function() { + done(); + }); + }); + // Remove tmp directory after each test afterEach(function(done) { fileSystem.rmdir(path.join(__dirname, '/tmp'), function(error) { @@ -126,6 +133,70 @@ describe('fileSystem', function() { }); + // rm method + describe('rm', function() { + + it('should return an error in case of invalid resource path', function(done) { + fileSystem.rm(null, function(error) { + if (error) + done(); + else + assert.ok(false, 'Expected an error'); + }); + }); + + describe('directory', function() { + var directoryPath = path.join(__dirname, '/tmp/rm'); + + // Create directory before each test + beforeEach(function(done) { + fileSystem.mkdir(directoryPath, function() { + done(); + }); + }); + + it('should be able to recursively remove a directory', function(done) { + fileSystem.rm(directoryPath, function(error) { + if (!error) { + fs.exists(directoryPath, function(exists) { + if (!exists) + done(); + else + assert.ok(false, 'Expected directory to be removed'); + }); + } else + assert.ok(false, 'Remove directory failed: ' + error.message); + }); + }); + }); + + describe('file', function() { + var filePath = path.join(__dirname, '/tmp/rm.txt'); + + // Create file before each test + beforeEach(function(done) { + fs.writeFile(filePath, 'Something', {encoding: 'utf8'}, function() { + done(); + }); + }); + + it('should be able to remove a file', function(done) { + fileSystem.rm(filePath, function(error) { + if (!error) { + fs.exists(filePath, function(exists) { + if (!exists) + done(); + else + assert.ok(false, 'Expected file to be removed'); + }); + } else + assert.ok(false, 'Remove file failed: ' + error.message); + }); + }); + + }); + }); + // getJSONFileContent method describe('getJSONFileContent', function() { var filePath = path.join(__dirname, '/resources/file.json'); diff --git a/tests/server/grunt/ngDpTask.js b/tests/server/grunt/ngDpTask.js new file mode 100644 index 0000000..cd2921c --- /dev/null +++ b/tests/server/grunt/ngDpTask.js @@ -0,0 +1,269 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var assert = require('chai').assert; +var ngDpTask = process.requireApi('lib/grunt/ngDpTask/ngDpTask.js'); +var utilApi = process.requireApi('lib/util.js'); + +describe('ngDpTask', function() { + var grunt; + var ngDpTaskFunction; + var resourcesDirPath = path.join(__dirname, 'ngDpTask/resources'); + var destinationFile = path.join(resourcesDirPath, 'resources.json'); + + function launchTask(filesToAdd, validate, options) { + ngDpTaskFunction.call({ + files: [ + { + src: filesToAdd, + dest: destinationFile + } + ], + async: function() { + return function() { + validate(); + }; + }, + options: function(opts) { + return utilApi.merge(opts, options); + } + }); + } + + // Mock + beforeEach(function() { + grunt = { + fail: { + fatal: function(error) { + throw error; + } + }, + verbose: { + writeln: function() {} + }, + file: { + write: function(filePath, data) { + fs.writeFileSync(filePath, data); + } + }, + log: { + oklns: function() {} + } + }; + }); + + // Prepare tests + beforeEach(function() { + ngDpTaskFunction = ngDpTask(grunt); + }); + + // Remove generated file after each test + afterEach(function(done) { + fs.unlink(destinationFile, function(error) { + delete require.cache[destinationFile]; + done(); + }); + }); + + it('should be able to order modules using module dependencies', function(done) { + var filesToAdd = [ + path.join(resourcesDirPath, 'modulesDependencies/module2/module2.module.js'), + path.join(resourcesDirPath, 'modulesDependencies/module1/module1.module.js'), + path.join(resourcesDirPath, 'modulesDependencies/module1/module1.css'), + path.join(resourcesDirPath, 'modulesDependencies/module2/module2.css') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal(resources.js[0], filesToAdd[1], 'Expected module1 JS to be loaded before module2'); + assert.equal(resources.js[1], filesToAdd[0], 'Expected module2 JS to be loaded after module1'); + assert.equal(resources.css[0], filesToAdd[2], 'Expected module1 CSS to be loaded before module2'); + assert.equal(resources.css[1], filesToAdd[3], 'Expected module2 CSS to be loaded after module1'); + done(); + } + + launchTask(filesToAdd, validate); + }); + + it('should be able to order modules before files of the different modules', function(done) { + var filesToAdd = [ + path.join(resourcesDirPath, 'modulesWithFiles/module2/module2.module.js'), + path.join(resourcesDirPath, 'modulesWithFiles/module1/module1.module.js'), + path.join(resourcesDirPath, 'modulesWithFiles/module1/module1.factory.js'), + path.join(resourcesDirPath, 'modulesWithFiles/module2/module2.factory.js') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal(resources.js[0], filesToAdd[1], 'Expected module1 JS to be loaded before module2'); + assert.equal(resources.js[1], filesToAdd[0], 'Expected module2 JS to be loaded after module1'); + assert.equal(resources.js.length, filesToAdd.length, 'Wrong number of JavaScript files'); + assert.includeMembers(resources.js, [filesToAdd[2], filesToAdd[3]], 'Wrong JavaScript files'); + done(); + } + + launchTask(filesToAdd, validate); + }); + + it('should be able to order a factory and its associated dependencies', function(done) { + var filesToAdd = [ + path.join(resourcesDirPath, 'factory/factory2.factory.js'), + path.join(resourcesDirPath, 'factory/factory1.factory.js') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal(resources.js[0], filesToAdd[1], 'Expected factory1 JS to be loaded before factory2'); + assert.equal(resources.js[1], filesToAdd[0], 'Expected factory2 JS to be loaded after factory1'); + done(); + } + + launchTask(filesToAdd, validate); + }); + + it('should be able to order a component and its associated dependencies', function(done) { + var filesToAdd = [ + path.join(resourcesDirPath, 'component/component.component.js'), + path.join(resourcesDirPath, 'component/component.controller.js') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal(resources.js[0], filesToAdd[1], 'Expected controller JS to be loaded before component'); + assert.equal(resources.js[1], filesToAdd[0], 'Expected component JS to be loaded after controller'); + done(); + } + + launchTask(filesToAdd, validate); + }); + + it('should be able to order a controller and its associated dependencies', function(done) { + var filesToAdd = [ + path.join(resourcesDirPath, 'controller/controller.controller.js'), + path.join(resourcesDirPath, 'controller/controller.factory.js') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal(resources.js[0], filesToAdd[1], 'Expected factory JS to be loaded before controller'); + assert.equal(resources.js[1], filesToAdd[0], 'Expected controller JS to be loaded after factory'); + done(); + } + + launchTask(filesToAdd, validate); + }); + + it('should be able to order a directive and its associated dependencies', function(done) { + var filesToAdd = [ + path.join(resourcesDirPath, 'directive/directive.directive.js'), + path.join(resourcesDirPath, 'directive/directive.controller.js') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal(resources.js[0], filesToAdd[1], 'Expected controller JS to be loaded before directive'); + assert.equal(resources.js[1], filesToAdd[0], 'Expected directive JS to be loaded after controller'); + done(); + } + + launchTask(filesToAdd, validate); + }); + + it('should be able to order an AngularJS element which contains a filter', function(done) { + var filesToAdd = [ + path.join(resourcesDirPath, 'filter/filter.factory.js'), + path.join(resourcesDirPath, 'filter/filter.filter.js') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal(resources.js[0], filesToAdd[1], 'Expected filter JS to be loaded before factory'); + assert.equal(resources.js[1], filesToAdd[0], 'Expected factory JS to be loaded after filter'); + done(); + } + + launchTask(filesToAdd, validate); + }); + + it('should be able to order a file containing an AngularJS config', function(done) { + var filesToAdd = [ + path.join(resourcesDirPath, 'config/config.js'), + path.join(resourcesDirPath, 'config/config.factory.js') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal(resources.js[0], filesToAdd[1], 'Expected factory JS to be loaded before config'); + assert.equal(resources.js[1], filesToAdd[0], 'Expected config JS to be loaded after factory'); + done(); + } + + launchTask(filesToAdd, validate); + }); + + it('should be able to handle circular dependencies', function(done) { + var filesToAdd = [ + path.join(resourcesDirPath, 'circularDependencies/circularDependencies.js'), + path.join(resourcesDirPath, 'circularDependencies/circularDependencies.factory.js') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal(resources.js.length, filesToAdd.length, 'Wrong number of JS files'); + assert.equal(resources.js[0], filesToAdd[1], 'Expected factory JS to be loaded before module'); + assert.equal(resources.js[1], filesToAdd[0], 'Expected module JS to be loaded after factory'); + done(); + } + + launchTask(filesToAdd, validate); + }); + + it('should be able to order a file containing AngularJS route definitions', function(done) { + var filesToAdd = [ + path.join(resourcesDirPath, 'routes/routes.js'), + path.join(resourcesDirPath, 'routes/routes.controller.js'), + path.join(resourcesDirPath, 'routes/routes.factory.js') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal( + resources.js[resources.js.length - 1], + filesToAdd[0], + 'Expected routes JS to be loaded after others' + ); + done(); + } + + launchTask(filesToAdd, validate); + }); + + it('should be able to replace base path by a prefix', function(done) { + var cssPrefix = '/new/css/prefix'; + var jsPrefix = '/new/js/prefix'; + var filesToAdd = [ + path.join(resourcesDirPath, 'component/component.component.js'), + path.join(resourcesDirPath, 'component/component.controller.js'), + path.join(resourcesDirPath, 'component/component.module.js'), + path.join(resourcesDirPath, 'component/component.css') + ]; + + function validate() { + var resources = require(destinationFile); + assert.equal(resources.css[0], filesToAdd[3].replace(resourcesDirPath, cssPrefix), 'Wrong css prefix'); + assert.equal(resources.js[0], filesToAdd[2].replace(resourcesDirPath, jsPrefix), 'Wrong js prefix'); + assert.equal(resources.js[1], filesToAdd[1].replace(resourcesDirPath, jsPrefix), 'Wrong js prefix'); + assert.equal(resources.js[2], filesToAdd[0].replace(resourcesDirPath, jsPrefix), 'Wrong js prefix'); + done(); + } + + launchTask(filesToAdd, validate, { + basePath: resourcesDirPath, + cssPrefix: cssPrefix, + jsPrefix: jsPrefix + }); + + }); + +}); diff --git a/tests/server/grunt/ngDpTask/resources/circularDependencies/circularDependencies.factory.js b/tests/server/grunt/ngDpTask/resources/circularDependencies/circularDependencies.factory.js new file mode 100644 index 0000000..55a534a --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/circularDependencies/circularDependencies.factory.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module').factory('factory', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/circularDependencies/circularDependencies.js b/tests/server/grunt/ngDpTask/resources/circularDependencies/circularDependencies.js new file mode 100644 index 0000000..14005b5 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/circularDependencies/circularDependencies.js @@ -0,0 +1,5 @@ +'use strict'; + +angular.module('module').run(['factory', function() { + +}]); diff --git a/tests/server/grunt/ngDpTask/resources/component/component.component.js b/tests/server/grunt/ngDpTask/resources/component/component.component.js new file mode 100644 index 0000000..54dd413 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/component/component.component.js @@ -0,0 +1,6 @@ +'use strict'; + +angular.module('module').component('component', { + controller: 'componentController', + bindings: {} +}); diff --git a/tests/server/grunt/ngDpTask/resources/component/component.controller.js b/tests/server/grunt/ngDpTask/resources/component/component.controller.js new file mode 100644 index 0000000..262186c --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/component/component.controller.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module').controller('componentController', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/component/component.css b/tests/server/grunt/ngDpTask/resources/component/component.css new file mode 100644 index 0000000..e69de29 diff --git a/tests/server/grunt/ngDpTask/resources/component/component.module.js b/tests/server/grunt/ngDpTask/resources/component/component.module.js new file mode 100644 index 0000000..8014a26 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/component/component.module.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module', []); diff --git a/tests/server/grunt/ngDpTask/resources/config/config.factory.js b/tests/server/grunt/ngDpTask/resources/config/config.factory.js new file mode 100644 index 0000000..55a534a --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/config/config.factory.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module').factory('factory', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/config/config.js b/tests/server/grunt/ngDpTask/resources/config/config.js new file mode 100644 index 0000000..72097cd --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/config/config.js @@ -0,0 +1,5 @@ +'use strict'; + +angular.module('module').config(['factory', function() { + +}]); diff --git a/tests/server/grunt/ngDpTask/resources/controller/controller.controller.js b/tests/server/grunt/ngDpTask/resources/controller/controller.controller.js new file mode 100644 index 0000000..beff505 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/controller/controller.controller.js @@ -0,0 +1,5 @@ +'use strict'; + +function controller() {} +angular.module('module').controller('controller', controller); +controller.$inject = ['factory']; diff --git a/tests/server/grunt/ngDpTask/resources/controller/controller.factory.js b/tests/server/grunt/ngDpTask/resources/controller/controller.factory.js new file mode 100644 index 0000000..55a534a --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/controller/controller.factory.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module').factory('factory', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/directive/directive.controller.js b/tests/server/grunt/ngDpTask/resources/directive/directive.controller.js new file mode 100644 index 0000000..e3eb360 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/directive/directive.controller.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module').controller('controller', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/directive/directive.directive.js b/tests/server/grunt/ngDpTask/resources/directive/directive.directive.js new file mode 100644 index 0000000..51aed99 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/directive/directive.directive.js @@ -0,0 +1,8 @@ +'use strict'; + +angular.module('module').directive('directive', function() { + return { + scope: {}, + controller: 'controller' + }; +}); diff --git a/tests/server/grunt/ngDpTask/resources/factory/factory1.factory.js b/tests/server/grunt/ngDpTask/resources/factory/factory1.factory.js new file mode 100644 index 0000000..94d267b --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/factory/factory1.factory.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module').factory('factory1', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/factory/factory2.factory.js b/tests/server/grunt/ngDpTask/resources/factory/factory2.factory.js new file mode 100644 index 0000000..b91714f --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/factory/factory2.factory.js @@ -0,0 +1,7 @@ +'use strict'; + +function factory2() { + +} +angular.module('module').factory('factory2', factory2); +factory2.$inject = ['factory1']; diff --git a/tests/server/grunt/ngDpTask/resources/filter/filter.factory.js b/tests/server/grunt/ngDpTask/resources/filter/filter.factory.js new file mode 100644 index 0000000..baf1602 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/filter/filter.factory.js @@ -0,0 +1,7 @@ +'use strict'; + +function factory($filter) { + $filter('filter')('test'); +} +angular.module('module').factory('factory1', factory); +factory.$inject = ['$filter']; diff --git a/tests/server/grunt/ngDpTask/resources/filter/filter.filter.js b/tests/server/grunt/ngDpTask/resources/filter/filter.filter.js new file mode 100644 index 0000000..3a948b2 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/filter/filter.filter.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module').filter('filter', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/modulesDependencies/module1/module1.css b/tests/server/grunt/ngDpTask/resources/modulesDependencies/module1/module1.css new file mode 100644 index 0000000..e69de29 diff --git a/tests/server/grunt/ngDpTask/resources/modulesDependencies/module1/module1.module.js b/tests/server/grunt/ngDpTask/resources/modulesDependencies/module1/module1.module.js new file mode 100644 index 0000000..ab12b0d --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/modulesDependencies/module1/module1.module.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module1', []); diff --git a/tests/server/grunt/ngDpTask/resources/modulesDependencies/module2/module2.css b/tests/server/grunt/ngDpTask/resources/modulesDependencies/module2/module2.css new file mode 100644 index 0000000..e69de29 diff --git a/tests/server/grunt/ngDpTask/resources/modulesDependencies/module2/module2.module.js b/tests/server/grunt/ngDpTask/resources/modulesDependencies/module2/module2.module.js new file mode 100644 index 0000000..c221491 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/modulesDependencies/module2/module2.module.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module2', ['module1']); diff --git a/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module1/module1.factory.js b/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module1/module1.factory.js new file mode 100644 index 0000000..1cb971e --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module1/module1.factory.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module1').factory('module1Factory', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module1/module1.module.js b/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module1/module1.module.js new file mode 100644 index 0000000..ab12b0d --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module1/module1.module.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module1', []); diff --git a/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module2/module2.factory.js b/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module2/module2.factory.js new file mode 100644 index 0000000..130edb6 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module2/module2.factory.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module2').factory('module2Factory', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module2/module2.module.js b/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module2/module2.module.js new file mode 100644 index 0000000..c221491 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/modulesWithFiles/module2/module2.module.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module2', ['module1']); diff --git a/tests/server/grunt/ngDpTask/resources/routes/routes.controller.js b/tests/server/grunt/ngDpTask/resources/routes/routes.controller.js new file mode 100644 index 0000000..e3eb360 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/routes/routes.controller.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module').controller('controller', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/routes/routes.factory.js b/tests/server/grunt/ngDpTask/resources/routes/routes.factory.js new file mode 100644 index 0000000..55a534a --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/routes/routes.factory.js @@ -0,0 +1,3 @@ +'use strict'; + +angular.module('module').factory('factory', function() {}); diff --git a/tests/server/grunt/ngDpTask/resources/routes/routes.js b/tests/server/grunt/ngDpTask/resources/routes/routes.js new file mode 100644 index 0000000..cd3ac59 --- /dev/null +++ b/tests/server/grunt/ngDpTask/resources/routes/routes.js @@ -0,0 +1,10 @@ +'use strict'; + +angular.module('module').config(['$routeProvider', function($routeProvider) { + $routeProvider.when('/', { + controller: 'controller', + resolve: { + resolve1: ['factory', function() {}] + } + }); +}]); diff --git a/tests/server/middlewares/imageProcessorMiddleware.js b/tests/server/middlewares/imageProcessorMiddleware.js new file mode 100644 index 0000000..97d052d --- /dev/null +++ b/tests/server/middlewares/imageProcessorMiddleware.js @@ -0,0 +1,472 @@ +'use strict'; + +var path = require('path'); +var fs = require('fs'); +var async = require('async'); +var assert = require('chai').assert; +var gm = require('gm').subClass({ + imageMagick: true +}); +var fileSystem = process.requireApi('lib/fileSystem.js'); +var imageProcessorMiddleware = process.requireApi('lib/middlewares/imageProcessorMiddleware.js'); + +/** + * Converts image file size as returned by gm into a Number. + * + * gm returns file size with the unit (B, KB etc.). This will convert this + * into Bytes. + * + * @param {String} fileSize The file size as returned by gm + * @return {Number} The file size in Bytes, 0 if something went wrong + */ +function convertFileSizeIntoNumber(fileSize) { + + // Extract B or KB from the Number + var fileSizeChunks = fileSize.match(/([0-9.]*)(.*)/); + + if (fileSizeChunks.length === 3) { + var unit = fileSizeChunks[2]; + if (unit === 'B') return parseFloat(fileSizeChunks[1]); + if (unit === 'KB') return parseFloat(fileSizeChunks[1]) * 1000; + } + + return 0; +} + +describe('imageProcessorMiddleware', function() { + var imagesDirectoryPath = path.join(__dirname, 'resources'); + var imagesCachePath = path.join(__dirname, 'tmp/.cache'); + var imageTypes = ['jpg', 'png', 'gif']; + var request; + var response; + + beforeEach(function() { + request = { + query: { + style: 'thumb-42' + }, + url: '/JPG.jpg' + }; + response = { + set: function() { + }, + download: function() { + } + }; + }); + + // Remove tmp directory after each test + afterEach(function(done) { + async.parallel([ + function(callback) { + fileSystem.rmdir(path.join(__dirname, '/tmp'), function(error) { + callback(); + }); + }, + function(callback) { + fileSystem.rmdir(path.join(__dirname, 'resources/.cache'), function(error) { + callback(); + }); + } + ], function(error) { + done(); + }); + }); + + // Build tests by image types + imageTypes.forEach(function(imageType) { + + describe('with a ' + imageType + ' file', function() { + var defaultImageSize; + var defaultFileSize; + + // Set mocks + beforeEach(function() { + request = { + query: { + style: 'thumb-42' + }, + url: '/' + imageType.toUpperCase() + '.' + imageType + }; + response = { + set: function() { + }, + download: function() { + } + }; + }); + + // Get file default information + beforeEach(function(done) { + var image = gm(path.join(imagesDirectoryPath, request.url)); + + async.parallel([ + function(callback) { + image.size(function(error, size) { + defaultImageSize = size; + callback(); + }); + }, + function(callback) { + image.filesize(function(error, fileSize) { + defaultFileSize = convertFileSizeIntoNumber(fileSize); + callback(); + }); + } + ], function(error) { + done(); + }); + }); + + it('should be able to generate a thumb', function(done) { + var expectedHeaders = { + 'x-timestamp': Date.now() + }; + var expectedWidth = 42; + var middleware = imageProcessorMiddleware( + imagesDirectoryPath, + imagesCachePath, + [ + { + id: request.query.style, + width: expectedWidth, + quality: 100 + } + ], + expectedHeaders + ); + + request.query.filename = 'Expected file name'; + response = { + set: function(headers) { + assert.strictEqual(headers, expectedHeaders, 'Wrong headers'); + }, + download: function(imagePath, fileName) { + assert.equal(imagePath, path.join(imagesCachePath, request.query.style, request.url)); + assert.equal(fileName, request.query.filename, 'Wrong file name'); + + var image = gm(imagePath); + + async.parallel([ + function(callback) { + image.size(function(error, size) { + if (error) return callback(error); + var imageRatio = size.width / size.height; + assert.equal(size.width, expectedWidth, 'Wrong image width'); + assert.equal(imageRatio, defaultImageSize.width / defaultImageSize.height, 'Wrong image height'); + callback(); + }); + }, + function(callback) { + image.filesize(function(error, fileSize) { + if (error) return callback(error); + assert.isBelow(convertFileSizeIntoNumber(fileSize), defaultFileSize, 'Wrong file size'); + callback(); + }); + } + ], function(error) { + assert.isNull(error, 'Unexpected error: ' + (error && error.message)); + done(); + }); + } + }; + + middleware( + request, + response, + function() { + assert.isOk(false, 'Unexpected call to next function'); + done(); + } + ); + }); + + }); + + }); + + it('should throw a TypeError if imagesDirectory is empty', function() { + var emptyValues = [0, undefined, '', null]; + + emptyValues.forEach(function(emptyValue) { + assert.throws(function() { + imageProcessorMiddleware( + emptyValue, + imagesCachePath, + [{}], + {} + ); + }, TypeError, null, 'Expected an error to be thrown for empty value: ' + emptyValue); + }); + }); + + it('should throw a TypeError if styles is empty', function() { + var emptyValues = [0, undefined, '', null, []]; + + emptyValues.forEach(function(emptyValue) { + assert.throws(function() { + imageProcessorMiddleware( + imagesDirectoryPath, + imagesCachePath, + emptyValue, + {} + ); + }, TypeError, null, 'Expected an error to be thrown for empty value: ' + emptyValue); + }); + }); + + it('should generate thumb in given cache directory', function(done) { + var middleware = imageProcessorMiddleware( + imagesDirectoryPath, + imagesCachePath, + [ + { + id: 'thumb-42', + width: 42, + quality: 100 + } + ] + ); + + response.download = function() { + fs.stat( + path.join(imagesCachePath, request.query.style, request.url), + function(error, stat) { + assert.isNull(error, 'Expected thumb to have been generated'); + assert.isOk(stat.isFile(), 'Expected resource to be a file'); + done(); + } + ); + }; + + middleware( + request, + response, + function() { + assert.isOk(false, 'Unexpected call to next function'); + done(); + } + ); + }); + + it('should be able to add response headers along with the image', function(done) { + var headersSent; + var expectedHeaders = { + 'first-header': 'first-header-value', + 'second-header': 'second-header-value' + }; + var middleware = imageProcessorMiddleware( + imagesDirectoryPath, + imagesCachePath, + [ + { + id: 'thumb-42', + width: 42, + quality: 100 + } + ], + expectedHeaders + ); + + response.set = function(headers) { + headersSent = headers; + }; + + response.download = function() { + assert.strictEqual(headersSent, expectedHeaders); + done(); + }; + + middleware( + request, + response, + function() { + assert.isOk(false, 'Unexpected call to next function'); + done(); + } + ); + }); + + it('should skip image processor if style is not specified', function(done) { + var middleware = imageProcessorMiddleware( + imagesDirectoryPath, + imagesCachePath, + [ + { + id: 'thumb-42', + width: 42, + quality: 100 + } + ] + ); + + request.query.style = null; + + response.download = function() { + assert.isOk(false, 'Unexpected call to download function'); + }; + + middleware( + request, + response, + function() { + fs.stat(imagesCachePath, function(error, stat) { + assert.isNotNull(error, 'Unexpected cache directory'); + done(); + }); + } + ); + }); + + it('should skip image processor if path does not correspond to an existing resource', function(done) { + var middleware = imageProcessorMiddleware( + imagesDirectoryPath, + imagesCachePath, + [ + { + id: 'thumb-42', + width: 42, + quality: 100 + } + ] + ); + + request.url = '/wrong-file'; + + response.download = function() { + assert.isOk(false, 'Unexpected call to download function'); + }; + + middleware( + request, + response, + function() { + fs.stat(imagesCachePath, function(error, stat) { + assert.isNotNull(error, 'Unexpected cache directory'); + done(); + }); + } + ); + }); + + it('should skip image processor if path does not correspond to a file', function(done) { + var middleware = imageProcessorMiddleware( + imagesDirectoryPath, + imagesCachePath, + [ + { + id: 'thumb-42', + width: 42, + quality: 100 + } + ] + ); + + request.url = '/'; + + response.download = function() { + assert.isOk(false, 'Unexpected call to download function'); + }; + + middleware( + request, + response, + function() { + fs.stat(imagesCachePath, function(error, stat) { + assert.isNotNull(error, 'Unexpected cache directory'); + done(); + }); + } + ); + }); + + it('should skip image processor if path does not correspond to an image', function(done) { + var middleware = imageProcessorMiddleware( + imagesDirectoryPath, + imagesCachePath, + [ + { + id: 'thumb-42', + width: 42, + quality: 100 + } + ] + ); + + request.url = '/text.txt'; + + response.download = function() { + assert.isOk(false, 'Unexpected call to download function'); + }; + + middleware( + request, + response, + function() { + fs.stat(imagesCachePath, function(error, stat) { + assert.isNotNull(error, 'Unexpected cache directory'); + done(); + }); + } + ); + }); + + it('should skip image processor if style is not part of supported styles', function(done) { + var middleware = imageProcessorMiddleware( + imagesDirectoryPath, + imagesCachePath, + [ + { + id: 'thumb-42', + width: 42, + quality: 100 + } + ] + ); + + request.query.style = 'invalid-style'; + + response.download = function() { + assert.isOk(false, 'Unexpected call to download function'); + }; + + middleware( + request, + response, + function() { + fs.stat(imagesCachePath, function(error, stat) { + assert.isNotNull(error, 'Unexpected cache directory'); + done(); + }); + } + ); + }); + + it('should skip image processor if style dimensions (width AND height) are missing', function(done) { + var middleware = imageProcessorMiddleware( + imagesDirectoryPath, + imagesCachePath, + [ + { + id: 'thumb-42', + quality: 100 + } + ] + ); + + response.download = function() { + assert.isOk(false, 'Unexpected call to download function'); + }; + + middleware( + request, + response, + function() { + fs.stat(imagesCachePath, function(error, stat) { + assert.isNotNull(error, 'Unexpected cache directory'); + done(); + }); + } + ); + }); +}); diff --git a/tests/server/middlewares/resources/GIF.gif b/tests/server/middlewares/resources/GIF.gif new file mode 100644 index 0000000..8412b52 Binary files /dev/null and b/tests/server/middlewares/resources/GIF.gif differ diff --git a/tests/server/middlewares/resources/JPG.jpg b/tests/server/middlewares/resources/JPG.jpg new file mode 100644 index 0000000..579c3cf Binary files /dev/null and b/tests/server/middlewares/resources/JPG.jpg differ diff --git a/tests/server/middlewares/resources/PNG.png b/tests/server/middlewares/resources/PNG.png new file mode 100644 index 0000000..72e8a32 Binary files /dev/null and b/tests/server/middlewares/resources/PNG.png differ diff --git a/tests/server/middlewares/resources/text.txt b/tests/server/middlewares/resources/text.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/server/models/ContentModel.js b/tests/server/models/ContentModel.js deleted file mode 100644 index f148692..0000000 --- a/tests/server/models/ContentModel.js +++ /dev/null @@ -1,337 +0,0 @@ -'use strict'; - -var util = require('util'); -var assert = require('chai').assert; -var ContentModel = process.requireApi('lib/models/ContentModel.js'); -var EntityProvider = process.requireApi('lib/providers/EntityProvider.js'); -var Database = process.requireApi('lib/database/Database.js'); -var AccessError = process.requireApi('lib/errors/AccessError.js'); - -// ContentModel.js -describe('ContentModel', function() { - var database; - var ADMIN_ID = '0'; - var ANONYMOUS_ID = '1'; - var provider; - var TestEntityProvider; - var TestContentModel; - - // Mocks - beforeEach(function() { - TestContentModel = function(user, provider) { - TestContentModel.super_.call(this, user, provider); - }; - - TestContentModel.prototype.getSuperAdminId = function() { - return ADMIN_ID; - }; - - TestContentModel.prototype.getAnonymousId = function() { - return ANONYMOUS_ID; - }; - - TestEntityProvider = function(database, collection) { - TestEntityProvider.super_.call(this, database, collection); - }; - - util.inherits(TestContentModel, ContentModel); - util.inherits(TestEntityProvider, EntityProvider); - }); - - // Prepare tests - beforeEach(function() { - database = new Database({}); - provider = new TestEntityProvider(database, 'my_collection'); - }); - - it('should expose user associated groups', function() { - var groupName = 'mygroup'; - var user = { - id: '42', - permissions: [ - 'get-group-' + groupName, - 'update-group-' + groupName, - 'wrongoperation-group-' + groupName - ] - }; - var model = new TestContentModel(user, provider); - - assert.sameMembers(['get', 'update'], model.groups[groupName]); - }); - - // isUserAdmin method - describe('isUserAdmin', function() { - - it('should be able to indicate if the associated user is the administrator', function() { - var user = {id: ADMIN_ID}; - var model = new TestContentModel(user, provider); - assert.ok(model.isUserAdmin(user), 'Expected user to be the administrator'); - - user.id = '42'; - assert.notOk(model.isUserAdmin(user), 'Expected user not to be the administrator'); - - user = null; - assert.notOk(model.isUserAdmin(user), 'Expected null not to be the administrator'); - }); - - }); - - // isUserAuthorized method - describe('isUserAuthorized', function() { - - it('should authorize the administrator to perform any kind of operation on all entities', function() { - var entity; - var user = {id: ADMIN_ID}; - var model = new TestContentModel(user, provider); - - entity = {metadata: {user: ANONYMOUS_ID}}; - assert.ok(model.isUserAuthorized(entity, 'get'), 'Expected admin to perform a get on an anonymous entity'); - assert.ok(model.isUserAuthorized(entity, 'update'), 'Expected admin to perform an update on an anonymous entity'); - assert.ok(model.isUserAuthorized(entity, 'delete'), 'Expected admin to perform a delete on an anonymous entity'); - - entity = {metadata: {user: '42', groups: ['mygroup']}}; - assert.ok(model.isUserAuthorized(entity, 'get'), 'Expected admin to perform a get on someone\'s entity'); - assert.ok(model.isUserAuthorized(entity, 'update'), 'Expected admin to perform an update on someone\'s entity'); - assert.ok(model.isUserAuthorized(entity, 'delete'), 'Expected admin to perform a delete on someone\'s entity'); - }); - - it('should authorize anyone to perform any kind of operation on anonymous entities', function() { - var entity; - var user = {id: '42'}; - var model = new TestContentModel(user, provider); - - entity = {metadata: {user: ANONYMOUS_ID}}; - assert.ok(model.isUserAuthorized(entity, 'get'), 'Expected anyone to perform a get on an anonymous entity'); - assert.ok(model.isUserAuthorized(entity, 'update'), - 'Expected anyone to perform an update on an anonymous entity'); - assert.ok(model.isUserAuthorized(entity, 'delete'), - 'Expected anyone to perform a delete on an anonymous entity'); - }); - - it('should authorize user with the right permission to perform operation on the entity', function() { - var groupName = 'mygroup'; - var entity; - var user = { - id: '42', - permissions: [ - 'get-group-' + groupName, - 'update-group-' + groupName, - 'delete-group-' + groupName - ] - }; - var model = new TestContentModel(user, provider); - - entity = {metadata: {user: '43', groups: ['mygroup']}}; - assert.ok(model.isUserAuthorized(entity, 'get'), 'Expected user to perform a get on an entity of his group'); - assert.ok(model.isUserAuthorized(entity, 'update'), - 'Expected anyone to perform an update on an entity of his group'); - assert.ok(model.isUserAuthorized(entity, 'delete'), - 'Expected anyone to perform a delete on an entity of his group'); - }); - - it('should not authorize a user to perform operation on the entity of another user', function() { - var entity; - var user = {id: '42'}; - var model = new TestContentModel(user, provider); - - entity = {metadata: {user: '43'}}; - assert.notOk(model.isUserAuthorized(entity, 'get'), 'Unexpected user to perform a get on someone else entity'); - assert.notOk(model.isUserAuthorized(entity, 'update'), - 'Expected user to perform an update on someone else entity'); - assert.notOk(model.isUserAuthorized(entity, 'delete'), - 'Expected user to perform a delete on someone else entity'); - }); - - it('should not authorize a user to perform operation on the entity of his group without permission', function() { - var model; - var groupName = 'mygroup'; - var entity = {metadata: {user: '43', groups: ['mygroup']}}; - var user = { - id: '42', - permissions: [ - 'update-group-' + groupName, - 'delete-group-' + groupName - ] - }; - - model = new TestContentModel(user, provider); - assert.notOk(model.isUserAuthorized(entity, 'get'), 'Unexpected user to perform a get without permission'); - - user.permissions = [ - 'get-group-' + groupName, - 'delete-group-' + groupName - ]; - model = new TestContentModel(user, provider); - assert.notOk(model.isUserAuthorized(entity, 'update'), 'Unexpected user to perform an update without permission'); - - user.permissions = [ - 'get-group-' + groupName, - 'update-group-' + groupName - ]; - model = new TestContentModel(user, provider); - assert.notOk(model.isUserAuthorized(entity, 'delete'), 'Unexpected user to perform a delete without permission'); - }); - - }); - - // getOne method - describe('getOne', function() { - - it('should be able to return an entity as retrieved by the provider', function() { - var expectedEntity = {}; - var user = {id: ADMIN_ID}; - TestEntityProvider.prototype.getOne = function(id, filter, callback) { - callback(null, expectedEntity); - }; - - provider = new TestEntityProvider(database, 'my_collection'); - var model = new TestContentModel(user, provider); - - model.getOne('42', null, function(error, entity) { - assert.strictEqual(entity, expectedEntity); - }); - }); - - it('should return an access error if user does not have the authorization to perform a get', function() { - var expectedEntity = {}; - var user = {id: '42'}; - TestEntityProvider.prototype.getOne = function(id, filter, callback) { - callback(null, expectedEntity); - }; - - provider = new TestEntityProvider(database, 'my_collection'); - var model = new TestContentModel(user, provider); - - model.getOne('1', null, function(error, entity) { - assert.ok(error instanceof AccessError); - }); - }); - - }); - - // update method - describe('update', function() { - - it('should be able to ask the provider to update an entity', function() { - var expectedEntity = {}; - var user = {id: ADMIN_ID}; - TestEntityProvider.prototype.getOne = function(id, filter, callback) { - callback(null, expectedEntity); - }; - TestEntityProvider.prototype.update = function(id, data, callback) { - callback(null, 1); - }; - - provider = new TestEntityProvider(database, 'my_collection'); - var model = new TestContentModel(user, provider); - - model.update('42', {}, function(error, updatedCount) { - assert.equal(updatedCount, 1); - }); - }); - - it('should return an access error if user does not have the authorization to perform an update', function() { - var expectedEntity = {}; - var user = {id: '42'}; - TestEntityProvider.prototype.getOne = function(id, filter, callback) { - callback(null, expectedEntity); - }; - TestEntityProvider.prototype.update = function(id, data, callback) { - assert.ok(false, 'Unexpected update request'); - }; - - provider = new TestEntityProvider(database, 'my_collection'); - var model = new TestContentModel(user, provider); - - model.getOne('1', null, function(error, entity) { - assert.ok(error instanceof AccessError); - }); - }); - - }); - - // remove method - describe('remove', function() { - - it('should be able to ask the provider to remove an entity', function() { - var expectedEntities = [{id: '42'}]; - var user = {id: ADMIN_ID}; - TestEntityProvider.prototype.get = function(filter, callback) { - callback(null, expectedEntities); - }; - TestEntityProvider.prototype.remove = function(ids, callback) { - callback(null, ids.length); - }; - - provider = new TestEntityProvider(database, 'my_collection'); - var model = new TestContentModel(user, provider); - - model.remove(expectedEntities[0].id, function(error, deletedCount) { - assert.equal(deletedCount, expectedEntities.length); - }); - }); - - it('should return an access error if user does not have the authorization to perform a delete', function() { - var expectedEntities = [{id: '1'}]; - var user = {id: '42'}; - TestEntityProvider.prototype.get = function(filter, callback) { - callback(null, expectedEntities); - }; - TestEntityProvider.prototype.remove = function(ids, callback) { - callback(null, ids.length); - }; - - provider = new TestEntityProvider(database, 'my_collection'); - var model = new TestContentModel(user, provider); - - model.remove(expectedEntities[0].id, function(error, deletedCount) { - assert.equal(deletedCount, 0); - }); - }); - - }); - - // addAccessFilter method - describe('addAccessFilter', function() { - - it('should not filter when user is the administrator', function() { - var user = {id: ADMIN_ID}; - var model = new TestContentModel(user, provider); - - var filter = model.addAccessFilter({}); - assert.notProperty(filter, '$or', 'Unexpected filter'); - }); - - it('should filter by the user id and the id of the anonymous user', function() { - var user = {id: '42'}; - var model = new TestContentModel(user, provider); - var filter = model.addAccessFilter({}); - - filter.$or.forEach(function(or) { - if (or['metadata.user']) - assert.sameMembers(or['metadata.user'].$in, [user.id, ANONYMOUS_ID], 'Expected to be filter by user id'); - }); - }); - - it('should filter by user groups', function() { - var groupName = 'mygroup'; - var groupName2 = 'mygroup2'; - var user = { - id: '42', - permissions: [ - 'get-group-' + groupName, - 'get-group-' + groupName2 - ] - }; - var model = new TestContentModel(user, provider); - var filter = model.addAccessFilter({}); - - filter.$or.forEach(function(or) { - if (or['metadata.groups']) - assert.sameMembers(or['metadata.groups'].$in, [groupName, groupName2], 'Expected to be filter by groups'); - }); - }); - - }); - -}); diff --git a/tests/server/models/EntityModel.js b/tests/server/models/EntityModel.js deleted file mode 100644 index 430a3c6..0000000 --- a/tests/server/models/EntityModel.js +++ /dev/null @@ -1,166 +0,0 @@ -'use strict'; - -var util = require('util'); -var chai = require('chai'); -var spies = require('chai-spies'); -var EntityModel = process.requireApi('lib/models/EntityModel.js'); -var EntityProvider = process.requireApi('lib/providers/EntityProvider.js'); -var Database = process.requireApi('lib/database/Database.js'); -var assert = chai.assert; - -chai.should(); -chai.use(spies); - -// ContentModel.js -describe('ContentModel', function() { - var TestProvider; - var provider; - - // Mocks - beforeEach(function() { - TestProvider = function(database, collection) { - TestProvider.super_.call(this, database, collection); - - this.getOne = function(id, filter, callback) { - callback(); - }; - - this.get = function(filter, callback) { - callback(); - }; - - this.update = function(id, data, callback) { - callback(); - }; - - this.remove = function(ids, callback) { - callback(); - }; - }; - - util.inherits(TestProvider, EntityProvider); - }); - - // Prepare tests - beforeEach(function() { - provider = new TestProvider(new Database({}), 'my_collection'); - }); - - describe('getOne', function() { - - it('should ask provider for the entity', function() { - var model = new EntityModel(provider); - var expectedId = 42; - var expectedFilter = {}; - var expectedCallback = function() {}; - provider.getOne = chai.spy(provider.getOne); - - model.getOne(expectedId, expectedFilter, expectedCallback); - - provider.getOne.should.have.been.called.with.exactly(expectedId, expectedFilter, expectedCallback); - }); - - }); - - describe('get', function() { - - it('should ask provider for the entities', function() { - var model = new EntityModel(provider); - var expectedFilter = {}; - var expectedCallback = function() {}; - provider.get = chai.spy(provider.get); - - model.get(expectedFilter, expectedCallback); - - provider.get.should.have.been.called.with.exactly(expectedFilter, expectedCallback); - }); - - }); - - // add method - describe('add', function() { - - it('should generate an id if not specified', function(done) { - var model = new EntityModel(provider); - provider.add = function(data, callback) { - assert.isString(data.id); - done(); - }; - - model.add({}); - }); - - it('should use given id if specified', function(done) { - var model = new EntityModel(provider); - var entity = {id: 42}; - provider.add = function(data, callback) { - assert.equal(data.id, entity.id); - done(); - }; - - model.add(entity); - }); - - it('should execute callback with the total inserted documents and the first document', function(done) { - var model = new EntityModel(provider); - var entity = {id: 42}; - var expectedDocuments = [entity]; - - provider.add = function(data, callback) { - callback(null, expectedDocuments.length, expectedDocuments); - }; - - model.add(entity, function(error, total, entity) { - assert.equal(total, expectedDocuments.length); - assert.strictEqual(entity, expectedDocuments[0]); - done(); - }); - }); - - it('should execute callback with an error if provider sends an error', function(done) { - var model = new EntityModel(provider); - var expectedError = new Error('Error message'); - - provider.add = function(data, callback) { - callback(expectedError); - }; - - model.add({}, function(error, total, entity) { - assert.strictEqual(error, expectedError); - done(); - }); - }); - }); - - describe('update', function() { - - it('should ask provider to update an entity', function() { - var model = new EntityModel(provider); - var expectedId = 42; - var expectedData = {}; - var expectedCallback = function() {}; - provider.update = chai.spy(provider.update); - - model.update(expectedId, expectedData, expectedCallback); - - provider.update.should.have.been.called.with.exactly(expectedId, expectedData, expectedCallback); - }); - - }); - - describe('remove', function() { - - it('should ask provider to remove entities', function() { - var model = new EntityModel(provider); - var expectedIds = [42]; - var expectedCallback = function() {}; - provider.remove = chai.spy(provider.remove); - - model.remove(expectedIds, expectedCallback); - - provider.remove.should.have.been.called.with.exactly(expectedIds, expectedCallback); - }); - - }); - -}); diff --git a/tests/server/providers/EntityProvider.js b/tests/server/providers/EntityProvider.js index a8fcfbc..ced2e84 100644 --- a/tests/server/providers/EntityProvider.js +++ b/tests/server/providers/EntityProvider.js @@ -1,211 +1,348 @@ 'use strict'; -var util = require('util'); +var path = require('path'); var assert = require('chai').assert; -var EntityProvider = process.requireApi('lib/providers/EntityProvider.js'); -var Database = process.requireApi('lib/database/Database.js'); +var mock = require('mock-require'); +var ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js'); -// EntityProvider.js describe('EntityProvider', function() { + var EntityProvider; + var Storage; + var storage; var provider; - var database; - var TestDatabase; + var expectedEntity; + var expectedEntities; + var expectedLocation = 'location'; - // Mocks + // Initiates mocks beforeEach(function() { - TestDatabase = function(conf) { - TestDatabase.super_.call(this, conf); + Storage = function() {}; + Storage.prototype.getOne = function(location, filter, fields, callback) { + callback(null, expectedEntity); + }; + Storage.prototype.get = function(location, filter, fields, limit, page, sort, callback) { + callback(null, expectedEntities, { + limit: limit, + page: page, + pages: Math.ceil(expectedEntities.length / limit), + size: expectedEntities.length + }); + }; + Storage.prototype.add = function(location, resources, callback) { + callback(null, resources.length); + }; + Storage.prototype.updateOne = function(location, filter, data, callback) { + callback(null, 42); + }; + Storage.prototype.remove = function(location, filter, callback) { + callback(null, 42); + }; + Storage.prototype.removeField = function(location, field, filter, callback) { + callback(null, 42); }; - util.inherits(TestDatabase, Database); + mock(path.join(process.rootApi, 'lib/storages/Storage.js'), Storage); }); - // Prepare tests + // Initiates tests beforeEach(function() { - database = new TestDatabase({}); - provider = new EntityProvider(database, 'collection'); + mock.reRequire(path.join(process.rootApi, 'lib/providers/Provider.js')); + EntityProvider = mock.reRequire(path.join(process.rootApi, 'lib/providers/EntityProvider.js')); + storage = new Storage({}); + provider = new EntityProvider(storage, expectedLocation); }); - // getOne function - describe('getOne', function() { + // Stop mocks + afterEach(function() { + mock.stopAll(); + }); - it('should remove _id property from results', function(done) { - database.get = function(collection, criteria, projection, limit, callback) { - assert.equal(projection['_id'], 0); - done(); - }; + describe('properties', function() { - provider.getOne(42); - }); + it('should not be editable', function() { + var properties = ['location']; + var provider = new EntityProvider(new Storage({}), 'location'); - it('should be able to get an entity by its id', function(done) { - var expectedId = 42; - database.get = function(collection, criteria, projection, limit, callback) { - assert.equal(criteria['id'], expectedId); - done(); - }; + properties.forEach(function(property) { + assert.throws(function() { + provider[property] = null; + }, null, null, 'Expected property "' + property + '" to be unalterable'); + }); - provider.getOne(expectedId); }); - it('should execute callback with the expected entity', function(done) { - var expectedEntity = {id: 42}; - database.get = function(collection, criteria, projection, limit, callback) { - callback(null, [expectedEntity]); - }; - provider.getOne(expectedEntity.id, null, function(error, entity) { - assert.strictEqual(expectedEntity, entity); - done(); + }); + + describe('constructor', function() { + + it('should throw a TypeError if location is not a String', function() { + var wrongValues = [true, 42, {}, [], function() {}]; + + wrongValues.forEach(function(wrongValue) { + assert.throws(function() { + new EntityProvider(new Storage({}), wrongValue); + }); }); }); - it('should execute callback with an error if an error occurred', function(done) { - var expectedError = new Error('Something went wrong'); - database.get = function(collection, criteria, projection, limit, callback) { - callback(expectedError); + }); + + describe('getOne', function() { + + it('should fetch an entity by a filter', function(done) { + var expectedFilter = new ResourceFilter(); + var expectedFields = { + include: ['field1'] }; - provider.getOne(42, null, function(error, entity) { - assert.strictEqual(expectedError, error, 'Expected an error'); - assert.isUndefined(entity, 'Unexpected entity'); - done(); - }); + expectedEntity = {id: 42}; + + storage.getOne = function(location, filter, fields, callback) { + assert.equal(location, expectedLocation, 'Wrong location'); + assert.strictEqual(filter, expectedFilter, 'Wrong filter'); + assert.strictEqual(fields, expectedFields, 'Wrong fields'); + callback(null, expectedEntity); + }; + + provider.getOne( + expectedFilter, + expectedFields, + function(error, entity) { + assert.isNull(error, 'Unexpected error'); + assert.strictEqual(entity, expectedEntity, 'Wrong entity'); + done(); + } + ); }); }); - // get function describe('get', function() { - it('should remove _id property from results', function(done) { - database.get = function(collection, criteria, projection, limit, callback) { - assert.equal(projection['_id'], 0); - done(); + it('should fetch a list of entities', function(done) { + var expectedFilter = new ResourceFilter(); + var expectedFields = { + include: ['field1'] + }; + var expectedLimit = 10; + var expectedPage = 42; + var expectedSort = {field: 'asc'}; + expectedEntities = [{}]; + + storage.get = function(location, filter, fields, limit, page, sort, callback) { + assert.equal(location, expectedLocation, 'Wrong location'); + assert.strictEqual(filter, expectedFilter, 'Wrong filter'); + assert.strictEqual(fields, expectedFields, 'Wrong fields'); + assert.equal(limit, expectedLimit, 'Wrong limit'); + assert.equal(page, expectedPage, 'Wrong page'); + assert.strictEqual(sort, expectedSort, 'Wrong sort'); + callback(null, expectedEntities); }; - provider.get(42); + provider.get( + expectedFilter, + expectedFields, + expectedLimit, + expectedPage, + expectedSort, + function(error, entities) { + assert.isNull(error, 'Unexpected error'); + assert.strictEqual(entities, expectedEntities, 'Wrong entity'); + done(); + } + ); }); }); - // add function describe('add', function() { - it('should be able to add several entities', function(done) { - var expectedEntities = [{id: 41}, {id: 42}]; - database.insert = function(collection, data, callback) { - assert.strictEqual(data, expectedEntities); - done(); + it('should add a list of entities', function(done) { + var expectedEntities = [ + { + id: '42', + field: 'value1' + }, + { + id: '43', + field: 'value2' + } + ]; + + storage.add = function(location, resources, callback) { + assert.equal(location, expectedLocation, 'Wrong location'); + assert.deepEqual(resources, expectedEntities, 'Wrong resources'); + callback(null, expectedEntities.length, expectedEntities); }; - provider.add(expectedEntities); + provider.add( + expectedEntities, + function(error, insertedCount, entities) { + assert.isNull(error, 'Unexpected error'); + assert.equal(insertedCount, expectedEntities.length, 'Wrong number of inserted entities'); + assert.strictEqual(entities, expectedEntities, 'Wrong entities'); + done(); + } + ); }); - it('should be able to add one entity', function(done) { - var expectedEntity = {id: 42}; - database.insert = function(collection, data, callback) { - assert.deepEqual(data, [expectedEntity]); - done(); - }; - - provider.add(expectedEntity); + it('should execute callback without results nor error if no entities specified', function(done) { + provider.add( + [], + function(error, insertedCount, entities) { + assert.isNull(error, 'Unexpected error'); + assert.equal(insertedCount, 0, 'Wrong number of inserted entities'); + assert.isUndefined(entities, 'Unexpected entities'); + done(); + } + ); }); - }); - // update function - describe('update', function() { + describe('updateOne', function() { - it('should be able to update an entity', function(done) { - var expectedId = 42; - database.update = function(collection, filter, data, callback) { - assert.equal(filter.id, expectedId); - done(); - }; - - provider.update(expectedId); - }); + it('should update an entity', function(done) { + var expectedFilter = new ResourceFilter(); + var expectedData = {field: 'value'}; + var expectedUpdatedCount = 1; - it('should not update a locked entity', function(done) { - database.update = function(collection, filter, data, callback) { - assert.ok(filter.locked.$ne); - done(); + storage.updateOne = function(location, filter, data, callback) { + assert.equal(location, expectedLocation, 'Wrong location'); + assert.strictEqual(filter, expectedFilter, 'Wrong filter'); + assert.strictEqual(data, expectedData, 'Wrong data'); + callback(null, expectedUpdatedCount); }; - provider.update(42); + provider.updateOne( + expectedFilter, + expectedData, + function(error, updatedCount) { + assert.isNull(error, 'Unexpected error'); + assert.equal(updatedCount, expectedUpdatedCount, 'Wrong updated count'); + done(); + } + ); }); }); - // remove function describe('remove', function() { - it('should be able to remove several entities', function(done) { - var expectedIds = [41, 42]; - database.remove = function(collection, filter, callback) { - assert.equal(filter.id.$in, expectedIds); - done(); - }; - - provider.remove(expectedIds); - }); + it('should remove a list of entities', function(done) { + var expectedFilter = new ResourceFilter(); + var expectedUpdatedCount = 42; - it('should not remove a locked entity', function(done) { - database.remove = function(collection, filter, callback) { - assert.ok(filter.locked.$ne); - done(); + storage.remove = function(location, filter, callback) { + assert.equal(location, expectedLocation, 'Wrong location'); + assert.strictEqual(filter, expectedFilter, 'Wrong filter'); + callback(null, expectedUpdatedCount); }; - provider.remove(42); + provider.remove( + expectedFilter, + function(error, updatedCount) { + assert.isNull(error, 'Unexpected error'); + assert.equal(updatedCount, expectedUpdatedCount, 'Wrong updated count'); + done(); + } + ); }); }); - // removeProp function - describe('removeProp', function() { + describe('removeField', function() { - it('should be able to remove a property from all entities', function(done) { - var expectedProperty = 'test'; - database.removeProp = function(collection, property, filter, callback) { - assert.equal(property, expectedProperty); - done(); - }; - - provider.removeProp(expectedProperty); - }); + it('should remove a field from a list of entities', function(done) { + var expectedFilter = new ResourceFilter(); + var expectedField = 'field'; + var expectedUpdatedCount = 42; - it('should not remove property on a locked entity', function(done) { - database.removeProp = function(collection, property, filter, callback) { - assert.ok(filter.locked.$ne); - done(); + storage.removeField = function(location, field, filter, callback) { + assert.equal(location, expectedLocation, 'Wrong location'); + assert.equal(field, expectedField, 'Wrong field'); + assert.strictEqual(filter, expectedFilter, 'Wrong filter'); + callback(null, expectedUpdatedCount); }; - provider.removeProp('test'); + provider.removeField( + expectedField, + expectedFilter, + function(error, updatedCount) { + assert.isNull(error, 'Unexpected error'); + assert.equal(updatedCount, expectedUpdatedCount, 'Wrong updated count'); + done(); + } + ); }); }); - // increase function - describe('increase', function() { + describe('getAll', function() { - it('should be able to increase a property of an entity', function(done) { - var expectedId = 42; - var expectedData = {test1: 41, test2: 42}; - database.increase = function(collection, filter, data, callback) { - assert.equal(filter.id, expectedId); - assert.strictEqual(data, expectedData); - done(); + it('should fetch all entities in all pages', function(done) { + var expectedPage = 0; + var expectedSize = 100; + var expectedFilter = new ResourceFilter(); + var expectedFields = { + include: ['field1'] + }; + var expectedSort = { + field1: 'asc' + }; + expectedEntities = []; + + for (var i = 0; i < expectedSize; i++) + expectedEntities.push({id: i}); + + storage.get = function(location, filter, fields, limit, page, sort, callback) { + assert.equal(location, expectedLocation, 'Wrong location'); + assert.strictEqual(filter, expectedFilter, 'Wrong filter'); + assert.strictEqual(fields, expectedFields, 'Wrong fields'); + assert.isNull(limit, 'Unexpected limit'); + assert.equal(page, expectedPage, 'Wrong page'); + assert.strictEqual(sort, expectedSort, 'Wrong sort'); + expectedPage++; + callback( + null, + expectedEntities.slice(page * 10, page * 10 + 10), + { + limit: 10, + page: page, + pages: Math.ceil(expectedEntities.length / 10), + size: expectedEntities.length + } + ); }; - provider.increase(expectedId, expectedData); + provider.getAll( + expectedFilter, + expectedFields, + expectedSort, + function(error, entities) { + assert.isNull(error, 'Unexpected error'); + assert.equal(expectedPage, (expectedSize / 10), 'Wrong number of pages'); + assert.deepEqual(entities, expectedEntities, 'Wrong entities'); + done(); + } + ); }); - it('should not increase a property on a locked entity', function(done) { - database.increase = function(collection, filter, data, callback) { - assert.ok(filter.locked.$ne); - done(); + it('should execute callback with an error if getting a page failed', function(done) { + var expectedError = new Error('Something went wrong'); + expectedEntities = []; + + storage.get = function(location, filter, fields, limit, page, sort, callback) { + callback(expectedError); }; - provider.increase(42); + provider.getAll( + new ResourceFilter(), + null, + null, + function(error, entities) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + } + ); }); }); diff --git a/tests/server/providers/Provider.js b/tests/server/providers/Provider.js index e0fda92..fa703fb 100644 --- a/tests/server/providers/Provider.js +++ b/tests/server/providers/Provider.js @@ -2,34 +2,21 @@ var assert = require('chai').assert; var Provider = process.requireApi('lib/providers/Provider.js'); -var Database = process.requireApi('lib/database/Database.js'); +var Storage = process.requireApi('lib/storages/Storage.js'); -// Provider.js describe('Provider', function() { + var provider; - // Constructor function - describe('constructor', function() { - - it('should throw a TypeError if database is not a Database', function() { - assert.throws(function() { - new Provider({}, 'collection'); - }); - }); - - it('should throw a TypeError if collection is not specified', function() { - assert.throws(function() { - new Provider(new Database({})); - }); - }); - + // Initializes tests + beforeEach(function() { + provider = new Provider(new Storage({})); }); - // properties describe('properties', function() { it('should not be editable', function() { - var provider = new Provider(new Database({}), 'collection'); - var properties = ['database', 'collection']; + var properties = ['storage']; + var provider = new Provider(new Storage({})); properties.forEach(function(property) { assert.throws(function() { @@ -41,4 +28,33 @@ describe('Provider', function() { }); + describe('constructor', function() { + + it('should throw a TypeError if storage is not an instance of Storage', function() { + var wrongValues = [true, 42, {}, [], function() {}, 'string']; + + wrongValues.forEach(function(wrongValue) { + assert.throws(function() { + new Provider(wrongValue); + }); + }); + }); + + }); + + describe('executeCallback', function() { + + it('should execute the given callback with arguments', function(done) { + var expectedParameter1 = 'value 1'; + var expectedParameter2 = 'value 2'; + + provider.executeCallback(function(param1, param2) { + assert.equal(param1, expectedParameter1, 'Wrong parameter 1'); + assert.equal(param2, expectedParameter2, 'Wrong parameter 2'); + done(); + }, expectedParameter1, expectedParameter2); + }); + + }); + }); diff --git a/tests/server/database/Database.js b/tests/server/storages/Database.js similarity index 72% rename from tests/server/database/Database.js rename to tests/server/storages/Database.js index 4675a09..dd81c0f 100644 --- a/tests/server/database/Database.js +++ b/tests/server/storages/Database.js @@ -1,16 +1,14 @@ 'use strict'; var assert = require('chai').assert; -var Database = process.requireApi('lib/database/Database.js'); +var Database = process.requireApi('lib/storages/databases/Database.js'); -// Database.js describe('Database', function() { - // properties describe('properties', function() { it('should not be editable', function() { - var properties = ['type', 'host', 'port', 'name', 'username', 'password']; + var properties = ['host', 'port', 'name', 'username', 'password']; var conf = {}; var database = new Database(conf); diff --git a/tests/server/storages/MongoDatabase.js b/tests/server/storages/MongoDatabase.js new file mode 100644 index 0000000..4a8f000 --- /dev/null +++ b/tests/server/storages/MongoDatabase.js @@ -0,0 +1,1302 @@ +'use strict'; + +var path = require('path'); +var chai = require('chai'); +var spies = require('chai-spies'); +var mock = require('mock-require'); +var ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js'); +var databaseErrors = process.requireApi('lib/storages/databases/databaseErrors.js'); +var MongoDatabase; +var MongoClientMock; +var MongoStoreMock; +var database; +var collection; +var cursor; +var commandCursor; +var documents; +var expectedDocuments; +var assert = chai.assert; + +chai.should(); +chai.use(spies); + +describe('MongoDatabase', function() { + + // Initiates mocks + beforeEach(function() { + MongoClientMock = { + connect: function() {} + }; + MongoStoreMock = function() {}; + + mock('mongodb', {MongoClient: MongoClientMock}); + mock('connect-mongo', function() { + return MongoStoreMock; + }); + }); + + // Load module to test + beforeEach(function() { + MongoDatabase = mock.reRequire(path.join(process.rootApi, 'lib/storages/databases/mongodb/MongoDatabase.js')); + database = new MongoDatabase({}); + database.db = { + collection: function(name, callback) { + callback(null, collection); + }, + listCollections: function(filter, options) { + return commandCursor; + } + }; + }); + + // Mock MongoDB Cursor and Collection + beforeEach(function() { + documents = []; + expectedDocuments = null; + + // Mock MongoDB Cursor + cursor = { + project: function() { + return cursor; + }, + sort: function() { + return cursor; + }, + skip: function(skip) { + var docs = expectedDocuments ? expectedDocuments : documents; + expectedDocuments = docs.slice(skip); + return cursor; + }, + limit: function(limit) { + var docs = expectedDocuments ? expectedDocuments : documents; + expectedDocuments = docs.slice(0, limit); + return cursor; + }, + count: function(applySkipLimit, options, callback) { + callback(null, documents.length); + }, + toArray: function(callback) { + callback(null, expectedDocuments); + } + }; + + // Mock MongoDB Collection + collection = { + find: function() { + return cursor; + }, + findOne: function(filter, projection, callback) { + var docs = expectedDocuments ? expectedDocuments : documents; + callback(docs[0] || null); + }, + rename: function(name, callback) { + callback(null); + } + }; + + // Mock MongoDB CommandCursor + commandCursor = { + toArray: function(callback) { + callback(); + } + }; + }); + + // Stop mocks + afterEach(function() { + mock.stopAll(); + }); + + describe('properties', function() { + + it('should not be editable', function() { + var properties = ['seedlist', 'replicaSet']; + var database = new MongoDatabase({}); + + properties.forEach(function(property) { + assert.throws(function() { + database[property] = null; + }, null, null, 'Expected property "' + property + '" to be unalterable'); + }); + + }); + + }); + + describe('connect', function() { + + it('should establish a connection to the database', function(done) { + var expectedDatabase = {}; + var configuration = { + username: 'username', + password: 'password', + host: '192.168.1.42', + port: '27017', + database: 'database', + seedlist: 'ip:port,ip:port', + replicaSet: 'rs' + }; + MongoClientMock.connect = function(url, callback) { + assert.ok( + url.indexOf( + 'mongodb://' + + configuration.username + ':' + + configuration.password + '@' + + configuration.host + ':' + + configuration.port + + ',' + configuration.seedlist + + '/' + configuration.database + + '?replicaSet=' + configuration.replicaSet + ) === 0, + 'Wrong MongoDB url' + ); + callback(null, expectedDatabase); + }; + + var database = new MongoDatabase(configuration); + database.connect(function() { + assert.strictEqual(database.db, expectedDatabase, 'Missing db property'); + done(); + }); + }); + + it('should establish a connection to the database without replicasets', function(done) { + var configuration = { + username: 'username', + password: 'password', + host: '192.168.1.42', + port: '27017', + database: 'database' + }; + MongoClientMock.connect = function(url, callback) { + assert.equal( + url, + 'mongodb://' + + configuration.username + ':' + + configuration.password + '@' + + configuration.host + ':' + + configuration.port + '/' + + configuration.database, + 'Wrong MongoDB url' + ); + callback(); + }; + + var database = new MongoDatabase(configuration); + database.connect(function(error) { + assert.isUndefined(error, 'Unexpected error'); + done(); + }); + }); + + it('should connect to the database without replicasets if seedlist is not specified', function(done) { + var configuration = { + username: 'username', + password: 'password', + host: '192.168.1.42', + port: '27017', + database: 'database', + replicaSet: 'rs' + }; + MongoClientMock.connect = function(url, callback) { + assert.equal( + url, + 'mongodb://' + + configuration.username + ':' + + configuration.password + '@' + + configuration.host + ':' + + configuration.port + '/' + + configuration.database, + 'Wrong MongoDB url' + ); + callback(); + }; + + var database = new MongoDatabase(configuration); + database.connect(function(error) { + assert.isUndefined(error, 'Unexpected error'); + done(); + }); + }); + + it('should connect to the database without replicasets if replicaSet is not specified', function(done) { + var configuration = { + username: 'username', + password: 'password', + host: '192.168.1.42', + port: '27017', + database: 'database', + seedlist: 'ip:port,ip:port' + }; + MongoClientMock.connect = function(url, callback) { + assert.equal( + url, + 'mongodb://' + + configuration.username + ':' + + configuration.password + '@' + + configuration.host + ':' + + configuration.port + '/' + + configuration.database, + 'Wrong MongoDB url' + ); + callback(); + }; + + var database = new MongoDatabase(configuration); + database.connect(function(error) { + assert.isUndefined(error, 'Unexpected error'); + done(); + }); + }); + + it('should execute callback with an error if connection failed', function(done) { + var expectedError = new Error('Something went wrong'); + var configuration = { + username: 'username', + password: 'password', + host: '192.168.1.42', + port: '27017', + database: 'database', + seedlist: 'ip:port,ip:port' + }; + MongoClientMock.connect = function(url, callback) { + callback(expectedError); + }; + + var database = new MongoDatabase(configuration); + database.connect(function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + }); + + describe('buildFilters', function() { + + it('should transform a ResourceFilter with comparison operations into a MongoDB filter equivalent', function() { + var filter = new ResourceFilter(); + var expectedOperations = [ + { + field: 'field1', + value: 'value1', + type: 'equal', + mongoOperator: '$eq' + }, + { + field: 'field2', + value: 'value2', + type: 'notEqual', + mongoOperator: '$ne' + }, + { + field: 'field3', + value: 'value3', + type: 'greaterThan', + mongoOperator: '$gt' + }, + { + field: 'field4', + value: 'value4', + type: 'greaterThanEqual', + mongoOperator: '$gte' + }, + { + field: 'field5', + value: 'value5', + type: 'lesserThan', + mongoOperator: '$lt' + }, + { + field: 'field6', + value: 'value6', + type: 'lesserThanEqual', + mongoOperator: '$lte' + }, + { + field: 'field7', + value: ['value7'], + type: 'in', + mongoOperator: '$in' + }, + { + field: 'field8', + value: ['value8'], + type: 'notIn', + mongoOperator: '$nin' + } + ]; + + expectedOperations.forEach(function(expectedOperation) { + filter[expectedOperation.type](expectedOperation.field, expectedOperation.value); + }); + + var builtFilter = MongoDatabase.buildFilter(filter); + + expectedOperations.forEach(function(expectedOperation) { + assert.equal(builtFilter[expectedOperation.field][expectedOperation.mongoOperator], expectedOperation.value); + }); + }); + + it('should handle different comparison operations on a same field', function() { + var filter = new ResourceFilter(); + var expectedOperations = [ + { + field: 'field', + value: '42', + type: 'equal', + mongoOperator: '$eq' + }, + { + field: 'field', + value: '43', + type: 'notEqual', + mongoOperator: '$ne' + }, + { + field: 'field', + value: '41', + type: 'greaterThan', + mongoOperator: '$gt' + }, + { + field: 'field', + value: '42', + type: 'greaterThanEqual', + mongoOperator: '$gte' + }, + { + field: 'field', + value: '43', + type: 'lesserThan', + mongoOperator: '$lt' + }, + { + field: 'field', + value: '42', + type: 'lesserThanEqual', + mongoOperator: '$lte' + }, + { + field: 'field', + value: ['42'], + type: 'in', + mongoOperator: '$in' + }, + { + field: 'field', + value: ['43'], + type: 'notIn', + mongoOperator: '$nin' + } + ]; + + expectedOperations.forEach(function(expectedOperation) { + filter[expectedOperation.type](expectedOperation.field, expectedOperation.value); + }); + + var builtFilter = MongoDatabase.buildFilter(filter); + + expectedOperations.forEach(function(expectedOperation) { + assert.equal(builtFilter[expectedOperation.field][expectedOperation.mongoOperator], expectedOperation.value); + }); + }); + + it('should transform a ResourceFilter with search operations into a MongoDB filter equivalent', function() { + var filter = new ResourceFilter(); + var expectedValue = 'search query'; + + filter.search(expectedValue); + + var builtFilter = MongoDatabase.buildFilter(filter); + assert.equal(builtFilter.$text.$search, expectedValue, 'Wrong value'); + }); + + it('should transform a ResourceFilter with logical operations into a MongoDB filter equivalent', function() { + var filter = new ResourceFilter(); + var expectedOperations = [ + { + filters: [ + new ResourceFilter().equal('field1', 'value1'), + new ResourceFilter().equal('field2', 'value2') + ], + type: 'or', + mongoOperator: '$or' + }, + { + filters: [ + new ResourceFilter().equal('field3', 'value3'), + new ResourceFilter().equal('field4', 'value4') + ], + type: 'nor', + mongoOperator: '$nor' + }, + { + filters: [ + new ResourceFilter().equal('field5', 'value5'), + new ResourceFilter().equal('field6', 'value6') + ], + type: 'and', + mongoOperator: '$and' + } + ]; + + expectedOperations.forEach(function(expectedOperation) { + filter[expectedOperation.type](expectedOperation.filters); + }); + + var builtFilter = MongoDatabase.buildFilter(filter); + + expectedOperations.forEach(function(expectedOperation) { + assert.equal( + builtFilter[expectedOperation.mongoOperator].length, + expectedOperation.filters.length, + 'Wrong number of sub filters' + ); + + for (var i = 0; i < expectedOperation.filters.length; i++) { + assert.deepEqual( + builtFilter[expectedOperation.mongoOperator][i], + MongoDatabase.buildFilter(expectedOperation.filters[i]), + 'Wrong sub filter ' + i + ); + } + }); + }); + + it('should throw an error if a ResourceFilter operation is not implemented', function() { + var filter = new ResourceFilter(); + filter.operations.push({ + type: 'Unkown operation type' + }); + + assert.throws(function() { + MongoDatabase.buildFilter(filter); + }); + }); + + it('should return an empty object if no filter', function() { + assert.isEmpty(MongoDatabase.buildFilter()); + }); + + }); + + describe('buildFields', function() { + + it('should build a MongoDB projection object to include fields', function() { + var expectedFields = ['field1', 'field2']; + var projection = MongoDatabase.buildFields(expectedFields, true); + + assert.equal(Object.keys(projection).length, expectedFields.length + 1, 'Wrong number of fields'); + + for (var field in projection) { + if (field !== '_id') + assert.equal(projection[field], 1, 'Expected field ' + field + ' to be included'); + else + assert.equal(projection[field], 0, 'Expected field ' + field + ' to be excluded'); + } + }); + + it('should build a MongoDB projection object to exclude from a list of fields', function() { + var expectedFields = ['field1', 'field2']; + var projection = MongoDatabase.buildFields(expectedFields, false); + + assert.equal(Object.keys(projection).length, expectedFields.length + 1, 'Wrong number of fields'); + + for (var field in projection) + assert.equal(projection[field], 0, 'Expected field ' + field + ' to be included'); + }); + + it('should build an empty MongoDB projection object if no fields only excluding "_id" field', function() { + var projection = MongoDatabase.buildFields(null, false); + assert.equal(Object.keys(projection).length, 1, 'Wrong projection'); + assert.equal(projection['_id'], 0, 'Unexpected field "_id"'); + }); + + }); + + describe('buildSort', function() { + + it('should build a MongoDB sort object', function() { + var expectedSort = { + field1: 'asc', + field2: 'desc' + }; + var sort = MongoDatabase.buildSort(expectedSort); + + for (var field in sort) + assert.equal(sort[field], expectedSort[field] === 'asc' ? 1 : -1, 'Wrong sort on ' + field); + }); + + }); + + describe('close', function() { + + it('should close connection to the database', function(done) { + database.db.close = function(callback) { + callback(); + }; + + database.close(function(error) { + assert.isUndefined(error, 'Unexpected error'); + done(); + }); + }); + + it('should execute callback with an error if something went wrong', function(done) { + var expectedError = new Error('Something went wrong'); + + database.db.close = function(callback) { + callback(expectedError); + }; + + database.close(function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should throw an error if database connection has not been established', function() { + assert.throws(function() { + database.close(function() {}); + }); + }); + + }); + + describe('add', function() { + + it('should be able to insert documents into a collection', function(done) { + var expectedCollection = 'collection'; + expectedDocuments = [{}]; + + collection.insertMany = function(results, callback) { + assert.strictEqual(results, expectedDocuments, 'Wrong documents'); + callback(null, {insertedCount: expectedDocuments.length, ops: expectedDocuments}); + }; + database.db.collection = function(name, callback) { + assert.equal(name, expectedCollection, 'Wrong collection'); + callback(null, collection); + }; + + database.add(expectedCollection, expectedDocuments, function(error, insertedCount, insertedDocuments) { + assert.isNull(error, 'Unexpected error'); + assert.equal(insertedCount, expectedDocuments.length, 'Wrong number of documents'); + assert.strictEqual(insertedDocuments, expectedDocuments, 'Wrong documents'); + done(); + }); + }); + + it('should execute callback with an error if connecting to the collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + + database.db.collection = function(name, callback) { + callback(expectedError); + }; + + database.add('collection', [{}], function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if inserting documents failed', function(done) { + var expectedError = new Error('Something went wrong'); + collection.insertMany = function(results, callback) { + callback(expectedError); + }; + + database.add('collection', [{}], function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should throw an error if database connection has not been established', function() { + assert.throws(function() { + database.add('collection', [{}], function() {}); + }); + }); + + }); + + describe('remove', function() { + + it('should be able to remove documents from a collection by their ids', function(done) { + var expectedCollection = 'collection'; + var expectedDeletedCount = 42; + var expectedFilter = new ResourceFilter().in('id', ['42', '43']); + + collection.deleteMany = function(filter, callback) { + assert.deepEqual(filter, MongoDatabase.buildFilter(expectedFilter), 'Wrong filter'); + callback(null, {deletedCount: expectedDeletedCount}); + }; + database.db.collection = function(name, callback) { + assert.equal(name, expectedCollection, 'Wrong collection'); + callback(null, collection); + }; + + database.remove( + expectedCollection, + expectedFilter, + function(error, deletedCount) { + assert.isNull(error, 'Unexpected error'); + assert.equal(deletedCount, expectedDeletedCount, 'Wrong number of documents'); + done(); + } + ); + }); + + it('should execute callback with an error if connecting to the collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + + database.db.collection = function(name, callback) { + callback(expectedError); + }; + + database.remove('collection', new ResourceFilter(), function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if removing documents failed', function(done) { + var expectedError = new Error('Something went wrong'); + collection.deleteMany = function(results, callback) { + callback(expectedError); + }; + + database.remove('collection', new ResourceFilter(), function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should throw an error if database connection has not been established', function() { + assert.throws(function() { + database.remove('collection', new ResourceFilter(), function() {}); + }); + }); + + }); + + describe('removeField', function() { + + it('should remove field from specified documents', function() { + var expectedCollection = 'collection'; + var expectedProperty = 'property'; + var expectedModifiedCount = 42; + var expectedFilter = new ResourceFilter().equal('id', '42'); + + collection.updateMany = function(filter, data, callback) { + expectedFilter = MongoDatabase.buildFilter(expectedFilter); + expectedFilter[expectedProperty] = {$exists: true}; + + assert.isEmpty(data.$unset[expectedProperty], 'Expected property to be empty'); + assert.deepEqual(filter, expectedFilter, 'Wrong filter'); + callback(null, {modifiedCount: expectedModifiedCount}); + }; + database.db.collection = function(name, callback) { + assert.equal(name, expectedCollection, 'Wrong collection'); + callback(null, collection); + }; + + database.removeField( + expectedCollection, + expectedProperty, + expectedFilter, + function(error, modifiedCount) { + assert.isNull(error, 'Unexpected error'); + assert.equal(modifiedCount, expectedModifiedCount, 'Wrong modified count'); + } + ); + + }); + + it('should execute callback with an error if connecting to the collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + + database.db.collection = function(collection, callback) { + callback(expectedError); + }; + + database.removeField('collection', 'property', new ResourceFilter(), function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if removing field failed', function(done) { + var expectedError = new Error('Something went wrong'); + + collection.updateMany = function(filter, data, callback) { + callback(expectedError); + }; + + database.removeField('collection', 'property', new ResourceFilter(), function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should throw an error if database connection has not been established', function() { + assert.throws(function() { + database.removeField('collection', 'property', new ResourceFilter(), function() {}); + }); + }); + + }); + + describe('updateOne', function() { + + it('should update a document', function() { + var expectedCollection = 'collection'; + var expectedModifiedCount = 1; + var expectedData = {}; + var expectedFilter = new ResourceFilter().equal('id', '42'); + + collection.updateOne = function(filter, data, callback) { + expectedFilter = MongoDatabase.buildFilter(expectedFilter); + + assert.strictEqual(data.$set, expectedData, 'Wrong data'); + assert.deepEqual(filter, expectedFilter, 'Wrong filter'); + callback(null, {modifiedCount: expectedModifiedCount}); + }; + + database.db.collection = function(name, callback) { + assert.equal(name, expectedCollection, 'Wrong collection'); + callback(null, collection); + }; + + database.updateOne( + expectedCollection, + expectedFilter, + expectedData, + function(error, modifiedCount) { + assert.isNull(error, 'Unexpected error'); + assert.equal(modifiedCount, expectedModifiedCount, 'Wrong modified count'); + } + ); + }); + + it('should execute callback with an error if connecting to the collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + + database.db.collection = function(name, callback) { + callback(expectedError); + }; + + database.updateOne('collection', new ResourceFilter(), {}, function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if updating the entity failed', function(done) { + var expectedError = new Error('Something went wrong'); + + collection.updateOne = function(filter, data, callback) { + callback(expectedError); + }; + + database.updateOne('collection', new ResourceFilter(), {}, function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should throw an error if database connection has not been established', function() { + assert.throws(function() { + database.updateOne('collection', new ResourceFilter(), {}, function() {}); + }); + }); + + }); + + describe('get', function() { + + beforeEach(function() { + for (var i = 0; i < 600; i++) { + documents.push({ + id: i, + field1: 'value1', + field2: 'value2' + }); + } + }); + + it('should get paginated documents', function(done) { + var expectedCollection = 'collection'; + var expectedFilter = new ResourceFilter().equal('field', 'value'); + + database.db.collection = function(name, callback) { + assert.equal(name, expectedCollection, 'Wrong collection'); + callback(null, collection); + }; + + collection.find = function(filter) { + assert.deepEqual(filter, MongoDatabase.buildFilter(expectedFilter), 'Wrong filter'); + return cursor; + }; + + database.get(expectedCollection, expectedFilter, null, null, null, null, function(error, results, pagination) { + assert.isNull(error, 'Unexpected error'); + assert.equal(results.length, expectedDocuments.length, 'Wrong documents'); + assert.equal(pagination.limit, 10, 'Wrong limit'); + assert.equal(pagination.page, 0, 'Wrong page'); + assert.equal(pagination.pages, Math.ceil(documents.length / 10), 'Wrong number of pages'); + assert.equal(pagination.size, documents.length, 'Wrong total'); + done(); + }); + }); + + it('should be able to limit the number of documents per page', function(done) { + var expectedLimit = 5; + + database.get( + 'collection', + new ResourceFilter(), + null, + expectedLimit, + null, + null, + function(error, results, pagination) { + assert.isNull(error, 'Unexpected error'); + assert.equal(results.length, expectedLimit, 'Wrong number of documents'); + assert.equal(results[0].id, 0, 'Wrong documents'); + assert.equal(pagination.limit, expectedLimit, 'Wrong limit'); + assert.equal(pagination.page, 0, 'Wrong page'); + assert.equal(pagination.pages, Math.ceil(documents.length / expectedLimit), 'Wrong number of pages'); + assert.equal(pagination.size, documents.length, 'Wrong total'); + done(); + } + ); + }); + + it('should be able to select a page from the paginated documents', function(done) { + var expectedPage = 2; + + database.get( + 'collection', + new ResourceFilter(), + null, + null, + expectedPage, + null, + function(error, results, pagination) { + assert.isNull(error, 'Unexpected error'); + assert.equal(results.length, 10, 'Wrong number of documents'); + assert.equal(results[0].id, 20, 'Wrong documents'); + assert.equal(pagination.limit, 10, 'Wrong limit'); + assert.equal(pagination.page, expectedPage, 'Wrong page'); + assert.equal(pagination.pages, Math.ceil(documents.length / 10), 'Wrong number of pages'); + assert.equal(pagination.size, documents.length, 'Wrong total'); + done(); + } + ); + }); + + it('should be able to include only certain fields from documents', function(done) { + var expectedFields = { + include: ['field1', 'field2'] + }; + + collection.project = function(fields) { + assert.deepEqual(fields, MongoDatabase.buildFields(expectedFields.include, true)); + return cursor; + }; + + database.get( + 'collection', + new ResourceFilter(), + expectedFields, + null, + null, + null, + function(error, results, pagination) { + done(); + } + ); + }); + + it('should be able to exclude only certain fields from documents', function(done) { + var expectedFields = { + exclude: ['field1', 'field2'] + }; + + collection.project = function(fields) { + assert.deepEqual(fields, MongoDatabase.buildFields(expectedFields.exclude, false)); + return cursor; + }; + + database.get( + 'collection', + new ResourceFilter(), + expectedFields, + null, + null, + null, + function(error, results, pagination) { + done(); + } + ); + }); + + it('should be able to sort documents', function(done) { + var expectedSort = { + field1: 'asc', + field2: 'desc' + }; + + collection.sort = function(sort) { + assert.deepEqual(sort, MongoDatabase.buildSort(expectedSort), 'Wrong sort'); + return cursor; + }; + + database.get( + 'collection', + new ResourceFilter(), + null, + null, + null, + expectedSort, + function(error, results, pagination) { + done(); + } + ); + }); + + it('should execute callback with an error if connecting to the collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + + database.db.collection = function(name, callback) { + callback(expectedError); + }; + + database.get('collection', null, null, null, null, null, function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if getting documents failed', function(done) { + var expectedError = new Error('Something went wrong'); + + cursor.toArray = function(callback) { + callback(expectedError); + }; + + database.get('collection', null, null, null, null, null, function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if counting documents failed', function(done) { + var expectedError = new Error('Something went wrong'); + + cursor.count = function(applySkipLimit, options, callback) { + callback(expectedError); + }; + + database.get('collection', null, null, null, null, null, function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + }); + + describe('getOne', function() { + + it('should get a single document', function(done) { + var expectedFilter = new ResourceFilter().equal('id', 42); + var expectedDocuments = [{}, {}]; + + collection.findOne = function(filter, projection, callback) { + assert.deepEqual(filter, MongoDatabase.buildFilter(expectedFilter)); + callback(null, expectedDocuments[0]); + }; + + database.getOne('collection', expectedFilter, null, function(error, document) { + assert.isNull(error, 'Unexpected error'); + assert.strictEqual(document, expectedDocuments[0], 'Wrong document'); + done(); + }); + }); + + it('should be able to include only certain fields from document', function(done) { + var expectedFields = { + include: ['field1', 'field2'] + }; + + collection.project = function(fields) { + assert.deepEqual(fields, MongoDatabase.buildFields(expectedFields.include, true)); + return cursor; + }; + + database.getOne('collection', null, expectedFields, function(error, document) { + assert.isNull(error, 'Unexpected error'); + done(); + }); + }); + + it('should be able to exclude only certain fields from the document', function(done) { + var expectedFields = { + exclude: ['field1', 'field2'] + }; + + collection.project = function(fields) { + assert.deepEqual(fields, MongoDatabase.buildFields(expectedFields.exclude, false)); + return cursor; + }; + + database.getOne('collection', null, expectedFields, function(error, document) { + assert.isNull(error, 'Unexpected error'); + done(); + }); + }); + + it('should execute callback with an error if connecting to the collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + + database.db.collection = function(name, callback) { + callback(expectedError); + }; + + database.getOne('collection', null, null, function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if getting document failed', function(done) { + var expectedError = new Error('Something went wrong'); + + collection.findOne = function(applySkipLimit, options, callback) { + callback(expectedError); + }; + + database.getOne('collection', null, null, function(error, document) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + }); + + describe('getIndexes', function() { + + it('should get indexes of a collection', function(done) { + var expectedIndexes = {}; + + collection.indexes = function(callback) { + callback(null, expectedIndexes); + }; + + database.getIndexes('collection', function(error, indexes) { + assert.isNull(error, 'Unexpected error'); + assert.strictEqual(indexes, expectedIndexes, 'Expected indexes'); + done(); + }); + }); + + it('should execute callback with an error if connecting to the collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + + database.db.collection = function(collection, callback) { + callback(expectedError); + }; + + database.getIndexes('collection', function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if getting indexes failed', function(done) { + var expectedError = new Error('Something went wrong'); + + collection.indexes = function(callback) { + callback(expectedError); + }; + + database.getIndexes('collection', function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + }); + + describe('createIndexes', function() { + + it('should create indexes for a collection', function(done) { + var expectedIndexes = {}; + + collection.createIndexes = function(indexes, callback) { + assert.strictEqual(indexes, expectedIndexes, 'Wrong indexes'); + callback(null, expectedIndexes); + }; + + database.createIndexes('collection', expectedIndexes, function(error) { + assert.isNull(error, 'Unexpected error'); + done(); + }); + }); + + it('should execute callback with an error if connecting to the collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + + database.db.collection = function(collection, callback) { + callback(expectedError); + }; + + database.createIndexes('collection', {}, function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if creating indexes failed', function(done) { + var expectedError = new Error('Something went wrong'); + + collection.createIndexes = function(indexes, callback) { + callback(expectedError); + }; + + database.createIndexes('collection', {}, function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + }); + + describe('renameCollection', function() { + + it('should rename a collection', function(done) { + var expectedName = 'new-name'; + + commandCursor.toArray = function(callback) { + callback(null, [expectedName]); + }; + + collection.rename = function(name, callback) { + assert.equal(name, expectedName, 'Wrong name'); + callback(null); + }; + + database.renameCollection('collection', expectedName, function(error) { + assert.isNull(error, 'Unexpected error'); + done(); + }); + }); + + it('should execute callback with an error if collection not found', function(done) { + commandCursor.toArray = function(callback) { + callback(null, []); + }; + + database.renameCollection('collection', 'new-name', function(error) { + assert.strictEqual(error.code, databaseErrors.RENAME_COLLECTION_NOT_FOUND_ERROR, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if fetching collections failed', function(done) { + var expectedError = new Error('Something went wrong'); + + commandCursor.toArray = function(callback) { + callback(expectedError); + }; + + database.renameCollection('collection', 'new-name', function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if connecting to the collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + + commandCursor.toArray = function(callback) { + callback(null, ['collection']); + }; + + database.db.collection = function(collection, callback) { + callback(expectedError); + }; + + database.renameCollection('collection', 'new-name', function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with an error if renaming collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + + commandCursor.toArray = function(callback) { + callback(null, ['collection']); + }; + + collection.rename = function(name, callback) { + callback(expectedError); + }; + + database.renameCollection('collection', 'new-name', function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + }); + + describe('removeCollection', function() { + + it('should remove a collection', function() { + var expectedCollection = 'collection'; + + database.db.dropCollection = function(name, callback) { + assert.equal(name, expectedCollection, 'Wrong name'); + callback(null); + }; + + commandCursor.toArray = function(callback) { + callback(null, [expectedCollection]); + }; + + database.removeCollection(expectedCollection, function(error) { + assert.isNull(error, 'Unexpected error'); + }); + }); + + it('should execute callback with an error if remove collection failed', function(done) { + var expectedError = new Error('Something went wrong'); + var expectedCollection = 'collection'; + + database.db.dropCollection = function(name, callback) { + callback(expectedError); + }; + + commandCursor.toArray = function(callback) { + callback(null, [expectedCollection]); + }; + + database.removeCollection('collection', function(error) { + assert.strictEqual(error, expectedError, 'Wrong error'); + done(); + }); + }); + + it('should execute callback with a storage error if collection is not found', function(done) { + database.removeCollection('Unknown collection', function(error) { + assert.strictEqual(error.code, databaseErrors.REMOVE_COLLECTION_NOT_FOUND_ERROR, 'Wrong error'); + done(); + }); + }); + + }); + +}); diff --git a/tests/server/storages/ResourceFilter.js b/tests/server/storages/ResourceFilter.js new file mode 100644 index 0000000..37a2413 --- /dev/null +++ b/tests/server/storages/ResourceFilter.js @@ -0,0 +1,289 @@ +'use strict'; + +var assert = require('chai').assert; +var ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js'); + +describe('ResourceFilter', function() { + var filter; + var comparisonOperators = [ + ResourceFilter.OPERATORS.EQUAL, + ResourceFilter.OPERATORS.NOT_EQUAL, + ResourceFilter.OPERATORS.GREATER_THAN, + ResourceFilter.OPERATORS.GREATER_THAN_EQUAL, + ResourceFilter.OPERATORS.LESSER_THAN, + ResourceFilter.OPERATORS.LESSER_THAN_EQUAL + ]; + var inOperators = [ + ResourceFilter.OPERATORS.IN, + ResourceFilter.OPERATORS.NOT_IN + ]; + var logicalOperators = [ + ResourceFilter.OPERATORS.OR, + ResourceFilter.OPERATORS.NOR, + ResourceFilter.OPERATORS.AND + ]; + + // Initiates tests + beforeEach(function() { + filter = new ResourceFilter(); + }); + + describe('properties', function() { + + it('should not be editable', function() { + var properties = ['operations']; + filter = new ResourceFilter(); + + properties.forEach(function(property) { + assert.throws(function() { + filter[property] = null; + }, null, null, 'Expected property "' + property + '" to be unalterable'); + }); + + }); + + }); + + comparisonOperators.forEach(function(comparisonOperator) { + + describe(comparisonOperator, function() { + + it('should add a "' + comparisonOperator + '" operation', function() { + var expectedField = 'id'; + var expectedValue = 42; + filter[comparisonOperator](expectedField, expectedValue); + + assert.equal(filter.operations.length, 1, 'Wrong number of operations'); + assert.equal(filter.operations[0].type, comparisonOperator, 'Wrong operation type'); + assert.equal(filter.operations[0].field, expectedField, 'Wrong operation field'); + assert.equal(filter.operations[0].value, expectedValue, 'Wrong operation value'); + }); + + it('should accept a value as Number, Date, String or Boolean', function() { + var expectedField = 'id'; + var validValues = [42, new Date(), 'String', false]; + var wrongValues = [{}, []]; + + validValues.forEach(function(validValue) { + assert.doesNotThrow(function() { + filter[comparisonOperator](expectedField, validValue); + }); + }); + + wrongValues.forEach(function(wrongValue) { + assert.throws(function() { + filter[comparisonOperator](expectedField, wrongValue); + }); + }); + }); + + it('should throw a TypeError if field is not a String', function() { + var wrongValues = [42, {}, [], true]; + + wrongValues.forEach(function(wrongValue) { + assert.throws(function() { + filter[comparisonOperator](wrongValue, 'Field value'); + }); + }); + }); + + }); + + }); + + inOperators.forEach(function(inOperator) { + + describe(inOperator, function() { + + it('should add a "' + inOperator + '" operation', function() { + var expectedField = 'id'; + var expectedValue = ['value1', 'value2']; + filter[inOperator](expectedField, expectedValue); + + assert.equal(filter.operations.length, 1, 'Wrong number of operations'); + assert.equal(filter.operations[0].type, inOperator, 'Wrong operation type'); + assert.equal(filter.operations[0].field, expectedField, 'Wrong operation field'); + assert.deepEqual(filter.operations[0].value, expectedValue, 'Wrong operation value'); + }); + + it('should accept an array of values as Number, Date, String or Boolean', function() { + var expectedField = 'id'; + var validValues = [42, new Date(), 'String', false]; + var wrongValues = [{}, []]; + + validValues.forEach(function(validValue) { + assert.doesNotThrow(function() { + filter[inOperator](expectedField, [validValue]); + }); + }); + + wrongValues.forEach(function(wrongValue) { + assert.throws(function() { + filter[inOperator](expectedField, [wrongValue]); + }); + }); + }); + + it('should throw a TypeError if value is not an Array', function() { + var wrongValues = ['String', 42, true, {}]; + + wrongValues.forEach(function(wrongValue) { + assert.throws(function() { + filter[inOperator]('id', wrongValue); + }); + }); + }); + + }); + + }); + + logicalOperators.forEach(function(logicalOperator) { + + describe(logicalOperator, function() { + + it('should add a "' + logicalOperator + '" operation', function() { + var expectedFilters = [new ResourceFilter(), new ResourceFilter()]; + filter[logicalOperator](expectedFilters); + + assert.equal(filter.operations[0].type, logicalOperator, 'Wrong operation type'); + assert.deepEqual(filter.operations[0].filters, expectedFilters, 'Wrong operation filters'); + }); + + it('should not add more than one "' + logicalOperator + '" operation', function() { + var filters = [new ResourceFilter(), new ResourceFilter()]; + var expectedFilters = filters.concat(filters); + filter[logicalOperator](filters); + filter[logicalOperator](filters); + + assert.equal(filter.operations[0].type, logicalOperator, 'Wrong operation type'); + assert.deepEqual(filter.operations[0].filters, expectedFilters, 'Wrong operation filters'); + }); + + it('should accept an array of ResourceFilter objects', function() { + var validValues = [new ResourceFilter()]; + var wrongValues = [{}, [], 42, new Date(), 'String', false]; + + validValues.forEach(function(validValue) { + assert.doesNotThrow(function() { + filter[logicalOperator](validValue); + }); + }); + + wrongValues.forEach(function(wrongValue) { + assert.throws(function() { + filter[logicalOperator](wrongValue); + }); + }); + }); + + }); + + }); + + describe(ResourceFilter.OPERATORS.SEARCH, function() { + + it('should add a "' + ResourceFilter.OPERATORS.SEARCH + '" operation', function() { + var expectedValue = 'query search'; + filter[ResourceFilter.OPERATORS.SEARCH](expectedValue); + + assert.equal(filter.operations[0].type, ResourceFilter.OPERATORS.SEARCH, 'Wrong operation type'); + assert.equal(filter.operations[0].value, expectedValue, 'Wrong operation value'); + }); + + it('should throw a TypeError if value is not a String', function() { + var wrongValues = [[], 42, true, {}]; + + wrongValues.forEach(function(wrongValue) { + assert.throws(function() { + filter[ResourceFilter.OPERATORS.SEARCH](wrongValue); + }); + }); + }); + + }); + + describe('hasOperation', function() { + + it('should return true if an operation type is already present in the list of operations', function() { + comparisonOperators.forEach(function(comparisonOperator) { + filter[comparisonOperator]('id', 'value'); + assert.ok(filter.hasOperation(comparisonOperator)); + }); + + inOperators.forEach(function(inOperator) { + filter[inOperator]('id', ['value']); + assert.ok(filter.hasOperation(inOperator)); + }); + + logicalOperators.forEach(function(logicalOperator) { + filter[logicalOperator]([new ResourceFilter()]); + assert.ok(filter.hasOperation(logicalOperator)); + }); + }); + + it('should return false if an operation type is not present in the list of operations', function() { + comparisonOperators.forEach(function(comparisonOperator) { + assert.notOk(filter.hasOperation(comparisonOperator)); + }); + + inOperators.forEach(function(inOperator) { + assert.notOk(filter.hasOperation(inOperator)); + }); + + logicalOperators.forEach(function(logicalOperator) { + assert.notOk(filter.hasOperation(logicalOperator)); + }); + }); + + }); + + describe('getComparisonOperation', function() { + + it('should return the first comparison operation corresponding to specified field and type', function() { + comparisonOperators.forEach(function(comparisonOperator) { + var expectedValue = 'value-' + comparisonOperator; + filter = new ResourceFilter(); + filter[comparisonOperator]('field', expectedValue); + var operation = filter.getComparisonOperation(comparisonOperator, 'field'); + assert.equal(operation.value, expectedValue, 'Wrong value for operator ' + comparisonOperator); + }); + + inOperators.forEach(function(inOperator) { + var expectedValue = ['value-' + inOperator]; + filter = new ResourceFilter(); + filter[inOperator]('field', expectedValue); + var operation = filter.getComparisonOperation(inOperator, 'field'); + assert.strictEqual(operation.value, expectedValue, 'Wrong value for operator ' + inOperator); + }); + }); + + it('should be able to get the comparison operator from filters contained in a logical operation', function() { + logicalOperators.forEach(function(logicalOperator) { + var expectedValue = 'value-' + logicalOperator; + filter = new ResourceFilter(); + filter[logicalOperator]([ + new ResourceFilter().and([new ResourceFilter().equal('field', expectedValue)]) + ]); + var operation = filter.getComparisonOperation(ResourceFilter.OPERATORS.EQUAL, 'field'); + assert.strictEqual(operation.value, expectedValue, 'Wrong value for operator ' + logicalOperator); + }); + }); + + it('should be able to get a search operator', function() { + var expectedSearch = 'query'; + var filter = new ResourceFilter().search(expectedSearch); + assert.equal( + filter.getComparisonOperation(ResourceFilter.OPERATORS.SEARCH).value, + expectedSearch, + 'Wrong search value' + ); + }); + + it('should return null if operation has not been found', function() { + assert.isNull(filter.getComparisonOperation(ResourceFilter.OPERATORS.EQUAL, 'field')); + }); + + }); + +}); diff --git a/tests/server/storages/Storage.js b/tests/server/storages/Storage.js new file mode 100644 index 0000000..bf84e50 --- /dev/null +++ b/tests/server/storages/Storage.js @@ -0,0 +1,38 @@ +'use strict'; + +var assert = require('chai').assert; +var Storage = process.requireApi('lib/storages/Storage.js'); + +describe('Storage', function() { + + describe('constructor', function() { + + it('should throw a TypeError if configuration is not an Object', function() { + var wrongValues = [true, 42, 'String', [], function() {}]; + + wrongValues.forEach(function(wrongValue) { + assert.throws(function() { + new Storage(wrongValue); + }); + }); + }); + + }); + + describe('properties', function() { + + it('should not be editable', function() { + var properties = ['configuration']; + var storage = new Storage({}); + + properties.forEach(function(property) { + assert.throws(function() { + storage[property] = null; + }, null, null, 'Expected property "' + property + '" to be unalterable'); + }); + + }); + + }); + +}); diff --git a/tests/server/storages/factory.js b/tests/server/storages/factory.js new file mode 100644 index 0000000..b676455 --- /dev/null +++ b/tests/server/storages/factory.js @@ -0,0 +1,24 @@ +'use strict'; + +var assert = require('chai').assert; +var factory = process.requireApi('lib/storages/factory.js'); +var MongoDatabase = process.requireApi('lib/storages/databases/mongodb/MongoDatabase.js'); + +describe('Storage factory', function() { + + describe('get', function() { + + it('should be able to instanciate a MongoDatabase', function() { + var database = factory.get('mongodb', {}); + assert.ok(database instanceof MongoDatabase); + }); + + it('should throw a TypeError if unknown storage type', function() { + assert.throws(function() { + factory.get('wrongType'); + }); + }); + + }); + +}); diff --git a/tests/server/util/util.js b/tests/server/util/util.js index 9f4bf8f..f4f8a9b 100644 --- a/tests/server/util/util.js +++ b/tests/server/util/util.js @@ -599,14 +599,14 @@ describe('util', function() { assert.equal(validatedObject.arrayProperty[0], value); }); - it('should ignore property if value is an object', function() { - var validatedObject = util.shallowValidateObject({ - arrayProperty: {} - }, { - arrayProperty: {type: 'array'} + it('should throw an error if value is an object', function() { + assert.throws(function() { + util.shallowValidateObject({ + arrayProperty: {} + }, { + arrayProperty: {type: 'array'} + }); }); - - assert.isUndefined(validatedObject.arrayProperty); }); it('should convert values which are not strings', function() { @@ -671,14 +671,14 @@ describe('util', function() { assert.equal(validatedObject.arrayProperty[0], value); }); - it('should ignore property if value is an object', function() { - var validatedObject = util.shallowValidateObject({ - arrayProperty: {} - }, { - arrayProperty: {type: 'array'} + it('should throw an error if value is an object', function() { + assert.throws(function() { + util.shallowValidateObject({ + arrayProperty: {} + }, { + arrayProperty: {type: 'array'} + }); }); - - assert.isUndefined(validatedObject.arrayProperty); }); it('should convert values which are not numbers', function() { @@ -730,14 +730,14 @@ describe('util', function() { assert.equal(validatedObject.arrayProperty.length, values.length); }); - it('should ignore property if value is an object', function() { - var validatedObject = util.shallowValidateObject({ - arrayProperty: {} - }, { - arrayProperty: {type: 'array'} + it('should throw an error if value is an object', function() { + assert.throws(function() { + util.shallowValidateObject({ + arrayProperty: {} + }, { + arrayProperty: {type: 'array'} + }); }); - - assert.isUndefined(validatedObject.arrayProperty); }); it('should ignore values which are not objects', function() {