* Converts import script to use public API endpoints The endpoints don't exist yet, but this is a good way to see how the implementation of the data structures differ. * Exposes public API endpoint to fetch user's sketches * Implements public API delete endpoint * Adds helper to create custom ApplicationError classes * Adds create project endpoint that understand API's data structure This transforms the nested tree of file data into a mongoose Project model * Returns '201 Created' to match API spec * Removes 'CustomError' variable assignment as it shows up in test output * transformFiles will return file validation errors * Tests API project controller * Tests toModel() * Creates default files if no root-level .html file is provided * Do not auto-generate a slug if it is provided Fixes a bug where the slug was auto-generated using the sketch name, even if a slug property had been provided. * Validates uniqueness of slugs for projects created by the public API * Adds tests for slug uniqueness * Configures node's Promise implementation for mongoose (fixes warnings) * Moves createProject tests to match controller location * Adds support for code to ApplicationErrors * deleteProject controller tests * getProjectsForUser controller tests - implements tests - update apiKey tests to use new User mocks * Ensure error objects have consistent property names `message` is used as a high-level description of the errors `detail` is optional and has an plain language explanation of the individual errors `errors` is an array of each individual problem from `detail` in a machine-readable format * Assert environment variables are provided at script start * Version public API * Expect "files" property to always be provided * Fixes linting error * Converts import script to use public API endpoints The endpoints don't exist yet, but this is a good way to see how the implementation of the data structures differ. * Exposes public API endpoint to fetch user's sketches * Implements public API delete endpoint * Adds helper to create custom ApplicationError classes * Adds create project endpoint that understand API's data structure This transforms the nested tree of file data into a mongoose Project model * Returns '201 Created' to match API spec * Removes 'CustomError' variable assignment as it shows up in test output * transformFiles will return file validation errors * Tests API project controller * Tests toModel() * Creates default files if no root-level .html file is provided * Do not auto-generate a slug if it is provided Fixes a bug where the slug was auto-generated using the sketch name, even if a slug property had been provided. * Validates uniqueness of slugs for projects created by the public API * Adds tests for slug uniqueness * Configures node's Promise implementation for mongoose (fixes warnings) * Moves createProject tests to match controller location * deleteProject controller tests * Adds support for code to ApplicationErrors * getProjectsForUser controller tests - implements tests - update apiKey tests to use new User mocks * Ensure error objects have consistent property names `message` is used as a high-level description of the errors `detail` is optional and has an plain language explanation of the individual errors `errors` is an array of each individual problem from `detail` in a machine-readable format * Assert environment variables are provided at script start * Version public API * Expect "files" property to always be provided * Fixes linting error * Checks that authenticated user has permission to create under this namespace Previously, the project was always created under the authenticated user's namespace, but this not obvious behaviour.
This commit is contained in:
parent
443232380c
commit
d44a058fd8
24 changed files with 1905 additions and 552 deletions
|
@ -1,5 +1,8 @@
|
|||
import { configure } from 'enzyme'
|
||||
import Adapter from 'enzyme-adapter-react-16'
|
||||
import '@babel/polyfill'
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
mongoose.Promise = global.Promise;
|
||||
|
||||
configure({ adapter: new Adapter() })
|
||||
|
|
5
server/controllers/__mocks__/aws.controller.js
Normal file
5
server/controllers/__mocks__/aws.controller.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const getObjectKey = jest.mock();
|
||||
export const deleteObjectsFromS3 = jest.fn();
|
||||
export const signS3 = jest.fn();
|
||||
export const copyObjectInS3 = jest.fn();
|
||||
export const listObjectsInS3ForUser = jest.fn();
|
|
@ -1,155 +0,0 @@
|
|||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { Response } from 'jest-express';
|
||||
|
||||
import { createMock } from '../../models/project';
|
||||
import createProject from '../project.controller/createProject';
|
||||
|
||||
jest.mock('../../models/project');
|
||||
|
||||
describe('project.controller', () => {
|
||||
describe('createProject()', () => {
|
||||
let ProjectMock;
|
||||
|
||||
beforeEach(() => {
|
||||
ProjectMock = createMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ProjectMock.restore();
|
||||
});
|
||||
|
||||
it('fails if create fails', (done) => {
|
||||
const error = new Error('An error');
|
||||
|
||||
ProjectMock
|
||||
.expects('create')
|
||||
.rejects(error);
|
||||
|
||||
const request = { user: {} };
|
||||
const response = new Response();
|
||||
|
||||
const promise = createProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.json).toHaveBeenCalledWith({ success: false });
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('extracts parameters from request body', (done) => {
|
||||
const request = {
|
||||
user: { _id: 'abc123' },
|
||||
body: {
|
||||
name: 'Wriggly worm',
|
||||
files: [{ name: 'file.js', content: 'var hello = true;' }]
|
||||
}
|
||||
};
|
||||
const response = new Response();
|
||||
|
||||
|
||||
ProjectMock
|
||||
.expects('create')
|
||||
.withArgs({
|
||||
user: 'abc123',
|
||||
name: 'Wriggly worm',
|
||||
files: [{ name: 'file.js', content: 'var hello = true;' }]
|
||||
})
|
||||
.resolves();
|
||||
|
||||
const promise = createProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.json).toHaveBeenCalled();
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
// TODO: This should be extracted to a new model object
|
||||
// so the controllers just have to call a single
|
||||
// method for this operation
|
||||
it('populates referenced user on project creation', (done) => {
|
||||
const request = { user: { _id: 'abc123' } };
|
||||
const response = new Response();
|
||||
|
||||
const result = {
|
||||
_id: 'abc123',
|
||||
id: 'abc123',
|
||||
name: 'Project name',
|
||||
serveSecure: false,
|
||||
files: []
|
||||
};
|
||||
|
||||
const resultWithUser = {
|
||||
...result,
|
||||
user: {}
|
||||
};
|
||||
|
||||
ProjectMock
|
||||
.expects('create')
|
||||
.withArgs({ user: 'abc123' })
|
||||
.resolves(result);
|
||||
|
||||
ProjectMock
|
||||
.expects('populate')
|
||||
.withArgs(result)
|
||||
.yields(null, resultWithUser)
|
||||
.resolves(resultWithUser);
|
||||
|
||||
const promise = createProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
const doc = response.json.mock.calls[0][0];
|
||||
|
||||
expect(response.json).toHaveBeenCalled();
|
||||
|
||||
expect(JSON.parse(JSON.stringify(doc))).toMatchObject(resultWithUser);
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('fails if referenced user population fails', (done) => {
|
||||
const request = { user: { _id: 'abc123' } };
|
||||
const response = new Response();
|
||||
|
||||
const result = {
|
||||
_id: 'abc123',
|
||||
id: 'abc123',
|
||||
name: 'Project name',
|
||||
serveSecure: false,
|
||||
files: []
|
||||
};
|
||||
|
||||
const error = new Error('An error');
|
||||
|
||||
ProjectMock
|
||||
.expects('create')
|
||||
.resolves(result);
|
||||
|
||||
ProjectMock
|
||||
.expects('populate')
|
||||
.yields(error)
|
||||
.resolves(error);
|
||||
|
||||
const promise = createProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.json).toHaveBeenCalledWith({ success: false });
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,7 +2,6 @@ import archiver from 'archiver';
|
|||
import format from 'date-fns/format';
|
||||
import isUrl from 'is-url';
|
||||
import jsdom, { serializeDocument } from 'jsdom';
|
||||
import isBefore from 'date-fns/is_before';
|
||||
import isAfter from 'date-fns/is_after';
|
||||
import request from 'request';
|
||||
import slugify from 'slugify';
|
||||
|
@ -10,9 +9,10 @@ import Project from '../models/project';
|
|||
import User from '../models/user';
|
||||
import { resolvePathToFile } from '../utils/filePath';
|
||||
import generateFileSystemSafeName from '../utils/generateFileSystemSafeName';
|
||||
import { deleteObjectsFromS3, getObjectKey } from './aws.controller';
|
||||
|
||||
export { default as createProject } from './project.controller/createProject';
|
||||
export { default as createProject, apiCreateProject } from './project.controller/createProject';
|
||||
export { default as deleteProject } from './project.controller/deleteProject';
|
||||
export { default as getProjectsForUser, apiGetProjectsForUser } from './project.controller/getProjectsForUser';
|
||||
|
||||
export function updateProject(req, res) {
|
||||
Project.findById(req.params.project_id, (findProjectErr, project) => {
|
||||
|
@ -84,37 +84,6 @@ export function getProject(req, res) {
|
|||
});
|
||||
}
|
||||
|
||||
function deleteFilesFromS3(files) {
|
||||
deleteObjectsFromS3(files.filter((file) => {
|
||||
if (file.url) {
|
||||
if (!process.env.S3_DATE || (
|
||||
process.env.S3_DATE &&
|
||||
isBefore(new Date(process.env.S3_DATE), new Date(file.createdAt)))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(file => getObjectKey(file.url)));
|
||||
}
|
||||
|
||||
export function deleteProject(req, res) {
|
||||
Project.findById(req.params.project_id, (findProjectErr, project) => {
|
||||
if (!project.user.equals(req.user._id)) {
|
||||
res.status(403).json({ success: false, message: 'Session does not match owner of project.' });
|
||||
return;
|
||||
}
|
||||
deleteFilesFromS3(project.files);
|
||||
Project.remove({ _id: req.params.project_id }, (removeProjectError) => {
|
||||
if (removeProjectError) {
|
||||
res.status(404).send({ message: 'Project with that id does not exist' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjectsForUserId(userId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
Project.find({ user: userId })
|
||||
|
@ -157,10 +126,6 @@ export function getProjectAsset(req, res) {
|
|||
});
|
||||
}
|
||||
|
||||
export function getProjectsForUserName(username) {
|
||||
|
||||
}
|
||||
|
||||
export function getProjects(req, res) {
|
||||
if (req.user) {
|
||||
getProjectsForUserId(req.user._id)
|
||||
|
@ -173,24 +138,6 @@ export function getProjects(req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
export function getProjectsForUser(req, res) {
|
||||
if (req.params.username) {
|
||||
User.findOne({ username: req.params.username }, (err, user) => {
|
||||
if (!user) {
|
||||
res.status(404).json({ message: 'User with that username does not exist.' });
|
||||
return;
|
||||
}
|
||||
Project.find({ user: user._id })
|
||||
.sort('-createdAt')
|
||||
.select('name files id createdAt updatedAt')
|
||||
.exec((innerErr, projects) => res.json(projects));
|
||||
});
|
||||
} else {
|
||||
// could just move this to client side
|
||||
res.json([]);
|
||||
}
|
||||
}
|
||||
|
||||
export function projectExists(projectId, callback) {
|
||||
Project.findById(projectId, (err, project) => (
|
||||
project ? callback(true) : callback(false)
|
||||
|
|
|
@ -0,0 +1,391 @@
|
|||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { Response } from 'jest-express';
|
||||
|
||||
import Project, { createMock, createInstanceMock } from '../../../models/project';
|
||||
import createProject, { apiCreateProject } from '../createProject';
|
||||
|
||||
jest.mock('../../../models/project');
|
||||
|
||||
describe('project.controller', () => {
|
||||
describe('createProject()', () => {
|
||||
let ProjectMock;
|
||||
|
||||
beforeEach(() => {
|
||||
ProjectMock = createMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ProjectMock.restore();
|
||||
});
|
||||
|
||||
it('fails if create fails', (done) => {
|
||||
const error = new Error('An error');
|
||||
|
||||
ProjectMock
|
||||
.expects('create')
|
||||
.rejects(error);
|
||||
|
||||
const request = { user: {} };
|
||||
const response = new Response();
|
||||
|
||||
const promise = createProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.json).toHaveBeenCalledWith({ success: false });
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('extracts parameters from request body', (done) => {
|
||||
const request = {
|
||||
user: { _id: 'abc123' },
|
||||
body: {
|
||||
name: 'Wriggly worm',
|
||||
files: [{ name: 'file.js', content: 'var hello = true;' }]
|
||||
}
|
||||
};
|
||||
const response = new Response();
|
||||
|
||||
|
||||
ProjectMock
|
||||
.expects('create')
|
||||
.withArgs({
|
||||
user: 'abc123',
|
||||
name: 'Wriggly worm',
|
||||
files: [{ name: 'file.js', content: 'var hello = true;' }]
|
||||
})
|
||||
.resolves();
|
||||
|
||||
const promise = createProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.json).toHaveBeenCalled();
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
// TODO: This should be extracted to a new model object
|
||||
// so the controllers just have to call a single
|
||||
// method for this operation
|
||||
it('populates referenced user on project creation', (done) => {
|
||||
const request = { user: { _id: 'abc123' } };
|
||||
const response = new Response();
|
||||
|
||||
const result = {
|
||||
_id: 'abc123',
|
||||
id: 'abc123',
|
||||
name: 'Project name',
|
||||
serveSecure: false,
|
||||
files: []
|
||||
};
|
||||
|
||||
const resultWithUser = {
|
||||
...result,
|
||||
user: {}
|
||||
};
|
||||
|
||||
ProjectMock
|
||||
.expects('create')
|
||||
.withArgs({ user: 'abc123' })
|
||||
.resolves(result);
|
||||
|
||||
ProjectMock
|
||||
.expects('populate')
|
||||
.withArgs(result)
|
||||
.yields(null, resultWithUser)
|
||||
.resolves(resultWithUser);
|
||||
|
||||
const promise = createProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
const doc = response.json.mock.calls[0][0];
|
||||
|
||||
expect(response.json).toHaveBeenCalled();
|
||||
|
||||
expect(JSON.parse(JSON.stringify(doc))).toMatchObject(resultWithUser);
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('fails if referenced user population fails', (done) => {
|
||||
const request = { user: { _id: 'abc123' } };
|
||||
const response = new Response();
|
||||
|
||||
const result = {
|
||||
_id: 'abc123',
|
||||
id: 'abc123',
|
||||
name: 'Project name',
|
||||
serveSecure: false,
|
||||
files: []
|
||||
};
|
||||
|
||||
const error = new Error('An error');
|
||||
|
||||
ProjectMock
|
||||
.expects('create')
|
||||
.resolves(result);
|
||||
|
||||
ProjectMock
|
||||
.expects('populate')
|
||||
.yields(error)
|
||||
.resolves(error);
|
||||
|
||||
const promise = createProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.json).toHaveBeenCalledWith({ success: false });
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiCreateProject()', () => {
|
||||
let ProjectMock;
|
||||
let ProjectInstanceMock;
|
||||
|
||||
beforeEach(() => {
|
||||
ProjectMock = createMock();
|
||||
ProjectInstanceMock = createInstanceMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ProjectMock.restore();
|
||||
ProjectInstanceMock.restore();
|
||||
});
|
||||
|
||||
it('returns 201 with id of created sketch', (done) => {
|
||||
const request = {
|
||||
user: { _id: 'abc123', username: 'alice' },
|
||||
params: { username: 'alice' },
|
||||
body: {
|
||||
name: 'My sketch',
|
||||
files: {}
|
||||
}
|
||||
};
|
||||
const response = new Response();
|
||||
|
||||
const result = {
|
||||
_id: 'abc123',
|
||||
id: 'abc123',
|
||||
name: 'Project name',
|
||||
serveSecure: false,
|
||||
files: []
|
||||
};
|
||||
|
||||
ProjectInstanceMock.expects('isSlugUnique')
|
||||
.resolves({ isUnique: true, conflictingIds: [] });
|
||||
|
||||
ProjectInstanceMock.expects('save')
|
||||
.resolves(new Project(result));
|
||||
|
||||
const promise = apiCreateProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
const doc = response.json.mock.calls[0][0];
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(201);
|
||||
expect(response.json).toHaveBeenCalled();
|
||||
|
||||
expect(JSON.parse(JSON.stringify(doc))).toMatchObject({
|
||||
id: 'abc123'
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('fails if slug is not unique', (done) => {
|
||||
const request = {
|
||||
user: { _id: 'abc123', username: 'alice' },
|
||||
params: { username: 'alice' },
|
||||
body: {
|
||||
name: 'My sketch',
|
||||
slug: 'a-slug',
|
||||
files: {}
|
||||
}
|
||||
};
|
||||
const response = new Response();
|
||||
|
||||
const result = {
|
||||
_id: 'abc123',
|
||||
id: 'abc123',
|
||||
name: 'Project name',
|
||||
// slug: 'a-slug',
|
||||
serveSecure: false,
|
||||
files: []
|
||||
};
|
||||
|
||||
ProjectInstanceMock.expects('isSlugUnique')
|
||||
.resolves({ isUnique: false, conflictingIds: ['cde123'] });
|
||||
|
||||
ProjectInstanceMock.expects('save')
|
||||
.resolves(new Project(result));
|
||||
|
||||
const promise = apiCreateProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
const doc = response.json.mock.calls[0][0];
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(409);
|
||||
expect(response.json).toHaveBeenCalled();
|
||||
|
||||
expect(JSON.parse(JSON.stringify(doc))).toMatchObject({
|
||||
message: 'Sketch Validation Failed',
|
||||
detail: 'Slug "a-slug" is not unique. Check cde123'
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('fails if user does not have permission', (done) => {
|
||||
const request = {
|
||||
user: { _id: 'abc123', username: 'alice' },
|
||||
params: {
|
||||
username: 'dana',
|
||||
},
|
||||
body: {
|
||||
name: 'My sketch',
|
||||
slug: 'a-slug',
|
||||
files: {}
|
||||
}
|
||||
};
|
||||
const response = new Response();
|
||||
|
||||
const result = {
|
||||
_id: 'abc123',
|
||||
id: 'abc123',
|
||||
name: 'Project name',
|
||||
serveSecure: false,
|
||||
files: []
|
||||
};
|
||||
|
||||
ProjectInstanceMock.expects('isSlugUnique')
|
||||
.resolves({ isUnique: true, conflictingIds: [] });
|
||||
|
||||
ProjectInstanceMock.expects('save')
|
||||
.resolves(new Project(result));
|
||||
|
||||
const promise = apiCreateProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.status).toHaveBeenCalledWith(401);
|
||||
expect(response.json).toHaveBeenCalled();
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('returns validation errors on files input', (done) => {
|
||||
const request = {
|
||||
user: { username: 'alice' },
|
||||
params: { username: 'alice' },
|
||||
body: {
|
||||
name: 'My sketch',
|
||||
files: {
|
||||
'index.html': {
|
||||
// missing content or url
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const response = new Response();
|
||||
|
||||
const promise = apiCreateProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
const doc = response.json.mock.calls[0][0];
|
||||
|
||||
const responseBody = JSON.parse(JSON.stringify(doc));
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(422);
|
||||
expect(responseBody.message).toBe('File Validation Failed');
|
||||
expect(responseBody.detail).not.toBeNull();
|
||||
expect(responseBody.errors.length).toBe(1);
|
||||
expect(responseBody.errors).toEqual([
|
||||
{ name: 'index.html', message: 'missing \'url\' or \'content\'' }
|
||||
]);
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('rejects file parameters not in object format', (done) => {
|
||||
const request = {
|
||||
user: { _id: 'abc123', username: 'alice' },
|
||||
params: { username: 'alice' },
|
||||
body: {
|
||||
name: 'Wriggly worm',
|
||||
files: [{ name: 'file.js', content: 'var hello = true;' }]
|
||||
}
|
||||
};
|
||||
const response = new Response();
|
||||
|
||||
const promise = apiCreateProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
const doc = response.json.mock.calls[0][0];
|
||||
|
||||
const responseBody = JSON.parse(JSON.stringify(doc));
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(422);
|
||||
expect(responseBody.message).toBe('File Validation Failed');
|
||||
expect(responseBody.detail).toBe('\'files\' must be an object');
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('rejects if files object in not provided', (done) => {
|
||||
const request = {
|
||||
user: { _id: 'abc123', username: 'alice' },
|
||||
params: { username: 'alice' },
|
||||
body: {
|
||||
name: 'Wriggly worm',
|
||||
// files: {} is missing
|
||||
}
|
||||
};
|
||||
const response = new Response();
|
||||
|
||||
const promise = apiCreateProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
const doc = response.json.mock.calls[0][0];
|
||||
|
||||
const responseBody = JSON.parse(JSON.stringify(doc));
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(422);
|
||||
expect(responseBody.message).toBe('File Validation Failed');
|
||||
expect(responseBody.detail).toBe('\'files\' must be an object');
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { Request, Response } from 'jest-express';
|
||||
|
||||
import Project, { createMock, createInstanceMock } from '../../../models/project';
|
||||
import User from '../../../models/user';
|
||||
import deleteProject from '../../project.controller/deleteProject';
|
||||
import { deleteObjectsFromS3 } from '../../aws.controller';
|
||||
|
||||
|
||||
jest.mock('../../../models/project');
|
||||
jest.mock('../../aws.controller');
|
||||
|
||||
describe('project.controller', () => {
|
||||
describe('deleteProject()', () => {
|
||||
let ProjectMock;
|
||||
let ProjectInstanceMock;
|
||||
|
||||
beforeEach(() => {
|
||||
ProjectMock = createMock();
|
||||
ProjectInstanceMock = createInstanceMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ProjectMock.restore();
|
||||
ProjectInstanceMock.restore();
|
||||
});
|
||||
|
||||
it('returns 403 if project is not owned by authenticated user', (done) => {
|
||||
const user = new User();
|
||||
const project = new Project();
|
||||
project.user = user;
|
||||
|
||||
const request = new Request();
|
||||
request.setParams({ project_id: project._id });
|
||||
request.user = { _id: 'abc123' };
|
||||
|
||||
const response = new Response();
|
||||
|
||||
ProjectMock
|
||||
.expects('findById')
|
||||
.resolves(project);
|
||||
|
||||
const promise = deleteProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
message: 'Authenticated user does not match owner of project'
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('returns 404 if project does not exist', (done) => {
|
||||
const user = new User();
|
||||
const project = new Project();
|
||||
project.user = user;
|
||||
|
||||
const request = new Request();
|
||||
request.setParams({ project_id: project._id });
|
||||
request.user = { _id: 'abc123' };
|
||||
|
||||
const response = new Response();
|
||||
|
||||
ProjectMock
|
||||
.expects('findById')
|
||||
.resolves(null);
|
||||
|
||||
const promise = deleteProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.status).toHaveBeenCalledWith(404);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
message: 'Project with that id does not exist'
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('deletes project and dependent files from S3 ', (done) => {
|
||||
const user = new User();
|
||||
const project = new Project();
|
||||
project.user = user;
|
||||
|
||||
const request = new Request();
|
||||
request.setParams({ project_id: project._id });
|
||||
request.user = { _id: user._id };
|
||||
|
||||
const response = new Response();
|
||||
|
||||
ProjectMock
|
||||
.expects('findById')
|
||||
.resolves(project);
|
||||
|
||||
ProjectInstanceMock.expects('remove')
|
||||
.yields();
|
||||
|
||||
const promise = deleteProject(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.status).toHaveBeenCalledWith(200);
|
||||
expect(response.json).not.toHaveBeenCalled();
|
||||
expect(deleteObjectsFromS3).toHaveBeenCalled();
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { Request, Response } from 'jest-express';
|
||||
|
||||
import { createMock } from '../../../models/user';
|
||||
import getProjectsForUser, { apiGetProjectsForUser } from '../../project.controller/getProjectsForUser';
|
||||
|
||||
jest.mock('../../../models/user');
|
||||
jest.mock('../../aws.controller');
|
||||
|
||||
describe('project.controller', () => {
|
||||
let UserMock;
|
||||
|
||||
beforeEach(() => {
|
||||
UserMock = createMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
UserMock.restore();
|
||||
});
|
||||
|
||||
describe('getProjectsForUser()', () => {
|
||||
it('returns empty array user not supplied as parameter', (done) => {
|
||||
const request = new Request();
|
||||
request.setParams({});
|
||||
const response = new Response();
|
||||
|
||||
const promise = getProjectsForUser(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.status).toHaveBeenCalledWith(200);
|
||||
expect(response.json).toHaveBeenCalledWith([]);
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('returns 404 if user does not exist', (done) => {
|
||||
const request = new Request();
|
||||
request.setParams({ username: 'abc123' });
|
||||
const response = new Response();
|
||||
|
||||
UserMock
|
||||
.expects('findOne')
|
||||
.withArgs({ username: 'abc123' })
|
||||
.yields(null, null);
|
||||
|
||||
const promise = getProjectsForUser(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.status).toHaveBeenCalledWith(404);
|
||||
expect(response.json).toHaveBeenCalledWith({ message: 'User with that username does not exist.' });
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('returns 500 on other errors', (done) => {
|
||||
const request = new Request();
|
||||
request.setParams({ username: 'abc123' });
|
||||
const response = new Response();
|
||||
|
||||
UserMock
|
||||
.expects('findOne')
|
||||
.withArgs({ username: 'abc123' })
|
||||
.yields(new Error(), null);
|
||||
|
||||
const promise = getProjectsForUser(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.status).toHaveBeenCalledWith(500);
|
||||
expect(response.json).toHaveBeenCalledWith({ message: 'Error fetching projects' });
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiGetProjectsForUser()', () => {
|
||||
it('returns validation error if user id not provided', (done) => {
|
||||
const request = new Request();
|
||||
request.setParams({});
|
||||
const response = new Response();
|
||||
|
||||
const promise = apiGetProjectsForUser(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.status).toHaveBeenCalledWith(422);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
message: 'Username not provided'
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
|
||||
it('returns 404 if user does not exist', (done) => {
|
||||
const request = new Request();
|
||||
request.setParams({ username: 'abc123' });
|
||||
const response = new Response();
|
||||
|
||||
UserMock
|
||||
.expects('findOne')
|
||||
.withArgs({ username: 'abc123' })
|
||||
.yields(null, null);
|
||||
|
||||
const promise = apiGetProjectsForUser(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.status).toHaveBeenCalledWith(404);
|
||||
expect(response.json).toHaveBeenCalledWith({ message: 'User with that username does not exist.' });
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
|
||||
it('returns 500 on other errors', (done) => {
|
||||
const request = new Request();
|
||||
request.setParams({ username: 'abc123' });
|
||||
const response = new Response();
|
||||
|
||||
UserMock
|
||||
.expects('findOne')
|
||||
.withArgs({ username: 'abc123' })
|
||||
.yields(new Error(), null);
|
||||
|
||||
const promise = apiGetProjectsForUser(request, response);
|
||||
|
||||
function expectations() {
|
||||
expect(response.status).toHaveBeenCalledWith(500);
|
||||
expect(response.json).toHaveBeenCalledWith({ message: 'Error fetching projects' });
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,4 +1,5 @@
|
|||
import Project from '../../models/project';
|
||||
import { toModel, FileValidationError, ProjectValidationError } from '../../domain-objects/Project';
|
||||
|
||||
export default function createProject(req, res) {
|
||||
let projectValues = {
|
||||
|
@ -30,3 +31,67 @@ export default function createProject(req, res) {
|
|||
.then(populateUserData)
|
||||
.catch(sendFailure);
|
||||
}
|
||||
|
||||
// TODO: What happens if you don't supply any files?
|
||||
export function apiCreateProject(req, res) {
|
||||
const params = Object.assign({ user: req.user._id }, req.body);
|
||||
|
||||
function sendValidationErrors(err, type, code = 422) {
|
||||
res.status(code).json({
|
||||
message: `${type} Validation Failed`,
|
||||
detail: err.message,
|
||||
errors: err.files,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Error handling to match spec
|
||||
function sendFailure(err) {
|
||||
res.status(500).end();
|
||||
}
|
||||
|
||||
function handleErrors(err) {
|
||||
if (err instanceof FileValidationError) {
|
||||
sendValidationErrors(err, 'File', err.code);
|
||||
} else if (err instanceof ProjectValidationError) {
|
||||
sendValidationErrors(err, 'Sketch', err.code);
|
||||
} else {
|
||||
sendFailure();
|
||||
}
|
||||
}
|
||||
|
||||
function checkUserHasPermission() {
|
||||
if (req.user.username !== req.params.username) {
|
||||
console.log('no permission');
|
||||
const error = new ProjectValidationError(`'${req.user.username}' does not have permission to create for '${req.params.username}'`);
|
||||
error.code = 401;
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
checkUserHasPermission();
|
||||
|
||||
const model = toModel(params);
|
||||
|
||||
return model.isSlugUnique()
|
||||
.then(({ isUnique, conflictingIds }) => {
|
||||
if (isUnique) {
|
||||
return model.save()
|
||||
.then((newProject) => {
|
||||
res.status(201).json({ id: newProject.id });
|
||||
});
|
||||
}
|
||||
|
||||
const error = new ProjectValidationError(`Slug "${model.slug}" is not unique. Check ${conflictingIds.join(', ')}`);
|
||||
error.code = 409;
|
||||
|
||||
throw error;
|
||||
})
|
||||
.then(checkUserHasPermission)
|
||||
.catch(handleErrors);
|
||||
} catch (err) {
|
||||
handleErrors(err);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
|
57
server/controllers/project.controller/deleteProject.js
Normal file
57
server/controllers/project.controller/deleteProject.js
Normal file
|
@ -0,0 +1,57 @@
|
|||
import isBefore from 'date-fns/is_before';
|
||||
import Project from '../../models/project';
|
||||
import { deleteObjectsFromS3, getObjectKey } from '../aws.controller';
|
||||
import createApplicationErrorClass from '../../utils/createApplicationErrorClass';
|
||||
|
||||
const ProjectDeletionError = createApplicationErrorClass('ProjectDeletionError');
|
||||
|
||||
function deleteFilesFromS3(files) {
|
||||
deleteObjectsFromS3(files.filter((file) => {
|
||||
if (file.url) {
|
||||
if (!process.env.S3_DATE || (
|
||||
process.env.S3_DATE &&
|
||||
isBefore(new Date(process.env.S3_DATE), new Date(file.createdAt)))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(file => getObjectKey(file.url)));
|
||||
}
|
||||
|
||||
export default function deleteProject(req, res) {
|
||||
function sendFailure(error) {
|
||||
res.status(error.code).json({ message: error.message });
|
||||
}
|
||||
|
||||
function sendProjectNotFound() {
|
||||
sendFailure(new ProjectDeletionError('Project with that id does not exist', { code: 404 }));
|
||||
}
|
||||
|
||||
function handleProjectDeletion(project) {
|
||||
if (project == null) {
|
||||
sendProjectNotFound();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!project.user.equals(req.user._id)) {
|
||||
sendFailure(new ProjectDeletionError('Authenticated user does not match owner of project', { code: 403 }));
|
||||
return;
|
||||
}
|
||||
|
||||
deleteFilesFromS3(project.files);
|
||||
|
||||
project.remove((removeProjectError) => {
|
||||
if (removeProjectError) {
|
||||
sendProjectNotFound();
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).end();
|
||||
});
|
||||
}
|
||||
|
||||
return Project.findById(req.params.project_id)
|
||||
.then(handleProjectDeletion)
|
||||
.catch(sendFailure);
|
||||
}
|
72
server/controllers/project.controller/getProjectsForUser.js
Normal file
72
server/controllers/project.controller/getProjectsForUser.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
import Project from '../../models/project';
|
||||
import User from '../../models/user';
|
||||
import { toApi as toApiProjectObject } from '../../domain-objects/Project';
|
||||
import createApplicationErrorClass from '../../utils/createApplicationErrorClass';
|
||||
|
||||
const UserNotFoundError = createApplicationErrorClass('UserNotFoundError');
|
||||
|
||||
function getProjectsForUserName(username) {
|
||||
return new Promise((resolve, reject) => {
|
||||
User.findOne({ username }, (err, user) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
reject(new UserNotFoundError());
|
||||
return;
|
||||
}
|
||||
|
||||
Project.find({ user: user._id })
|
||||
.sort('-createdAt')
|
||||
.select('name files id createdAt updatedAt')
|
||||
.exec((innerErr, projects) => {
|
||||
if (innerErr) {
|
||||
reject(innerErr);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(projects);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default function getProjectsForUser(req, res) {
|
||||
if (req.params.username) {
|
||||
return getProjectsForUserName(req.params.username)
|
||||
.then(projects => res.json(projects))
|
||||
.catch((err) => {
|
||||
if (err instanceof UserNotFoundError) {
|
||||
res.status(404).json({ message: 'User with that username does not exist.' });
|
||||
} else {
|
||||
res.status(500).json({ message: 'Error fetching projects' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// could just move this to client side
|
||||
res.status(200).json([]);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
export function apiGetProjectsForUser(req, res) {
|
||||
if (req.params.username) {
|
||||
return getProjectsForUserName(req.params.username)
|
||||
.then((projects) => {
|
||||
const asApiObjects = projects.map(p => toApiProjectObject(p));
|
||||
res.json({ sketches: asApiObjects });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof UserNotFoundError) {
|
||||
res.status(404).json({ message: 'User with that username does not exist.' });
|
||||
} else {
|
||||
res.status(500).json({ message: 'Error fetching projects' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.status(422).json({ message: 'Username not provided' });
|
||||
return Promise.resolve();
|
||||
}
|
|
@ -1,73 +1,40 @@
|
|||
/* @jest-environment node */
|
||||
|
||||
import last from 'lodash/last';
|
||||
import { Request, Response } from 'jest-express';
|
||||
|
||||
import User, { createMock, createInstanceMock } from '../../../models/user';
|
||||
import { createApiKey, removeApiKey } from '../../user.controller/apiKey';
|
||||
|
||||
jest.mock('../../../models/user');
|
||||
|
||||
/*
|
||||
Create a mock object representing an express Response
|
||||
*/
|
||||
const createResponseMock = function createResponseMock(done) {
|
||||
const json = jest.fn(() => {
|
||||
if (done) { done(); }
|
||||
});
|
||||
|
||||
const status = jest.fn(() => ({ json }));
|
||||
|
||||
return {
|
||||
status,
|
||||
json
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
Create a mock of the mongoose User model
|
||||
*/
|
||||
const createUserMock = function createUserMock() {
|
||||
const apiKeys = [];
|
||||
let nextId = 0;
|
||||
|
||||
apiKeys.push = ({ label, hashedKey }) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
const publicFields = { id, label };
|
||||
const allFields = { ...publicFields, hashedKey };
|
||||
|
||||
Object.defineProperty(allFields, 'toObject', {
|
||||
value: () => publicFields,
|
||||
enumerable: false
|
||||
});
|
||||
|
||||
return Array.prototype.push.call(apiKeys, allFields);
|
||||
};
|
||||
|
||||
apiKeys.pull = ({ _id }) => {
|
||||
const index = apiKeys.findIndex(({ id }) => id === _id);
|
||||
return apiKeys.splice(index, 1);
|
||||
};
|
||||
|
||||
return {
|
||||
apiKeys,
|
||||
save: jest.fn(callback => callback())
|
||||
};
|
||||
};
|
||||
|
||||
const User = require('../../../models/user').default;
|
||||
|
||||
describe('user.controller', () => {
|
||||
let UserMock;
|
||||
let UserInstanceMock;
|
||||
|
||||
beforeEach(() => {
|
||||
User.__setFindById(null, null);
|
||||
UserMock = createMock();
|
||||
UserInstanceMock = createInstanceMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
UserMock.restore();
|
||||
UserInstanceMock.restore();
|
||||
});
|
||||
|
||||
|
||||
describe('createApiKey', () => {
|
||||
it('returns an error if user doesn\'t exist', () => {
|
||||
const request = { user: { id: '1234' } };
|
||||
const response = createResponseMock();
|
||||
const response = new Response();
|
||||
|
||||
UserMock
|
||||
.expects('findById')
|
||||
.withArgs('1234')
|
||||
.yields(null, null);
|
||||
|
||||
createApiKey(request, response);
|
||||
|
||||
expect(User.findById.mock.calls[0][0]).toBe('1234');
|
||||
expect(response.status).toHaveBeenCalledWith(404);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
error: 'User not found'
|
||||
|
@ -75,10 +42,13 @@ describe('user.controller', () => {
|
|||
});
|
||||
|
||||
it('returns an error if label not provided', () => {
|
||||
User.__setFindById(undefined, createUserMock());
|
||||
|
||||
const request = { user: { id: '1234' }, body: {} };
|
||||
const response = createResponseMock();
|
||||
const response = new Response();
|
||||
|
||||
UserMock
|
||||
.expects('findById')
|
||||
.withArgs('1234')
|
||||
.yields(null, new User());
|
||||
|
||||
createApiKey(request, response);
|
||||
|
||||
|
@ -89,46 +59,59 @@ describe('user.controller', () => {
|
|||
});
|
||||
|
||||
it('returns generated API key to the user', (done) => {
|
||||
let response;
|
||||
const request = new Request();
|
||||
request.setBody({ label: 'my key' });
|
||||
request.user = { id: '1234' };
|
||||
|
||||
const request = {
|
||||
user: { id: '1234' },
|
||||
body: { label: 'my key' }
|
||||
};
|
||||
const response = new Response();
|
||||
|
||||
const user = createUserMock();
|
||||
const user = new User();
|
||||
|
||||
const checkExpecations = () => {
|
||||
UserMock
|
||||
.expects('findById')
|
||||
.withArgs('1234')
|
||||
.yields(null, user);
|
||||
|
||||
UserInstanceMock.expects('save')
|
||||
.yields();
|
||||
|
||||
function expectations() {
|
||||
const lastKey = last(user.apiKeys);
|
||||
|
||||
expect(lastKey.label).toBe('my key');
|
||||
expect(typeof lastKey.hashedKey).toBe('string');
|
||||
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
apiKeys: [
|
||||
{ id: 0, label: 'my key', token: lastKey.hashedKey }
|
||||
]
|
||||
const responseData = response.json.mock.calls[0][0];
|
||||
|
||||
expect(responseData.apiKeys.length).toBe(1);
|
||||
expect(responseData.apiKeys[0]).toMatchObject({
|
||||
label: 'my key',
|
||||
token: lastKey.hashedKey,
|
||||
lastUsedAt: undefined,
|
||||
createdAt: undefined
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
}
|
||||
|
||||
response = createResponseMock(checkExpecations);
|
||||
const promise = createApiKey(request, response);
|
||||
|
||||
User.__setFindById(undefined, user);
|
||||
|
||||
createApiKey(request, response);
|
||||
promise.then(expectations, expectations).catch(expectations);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeApiKey', () => {
|
||||
it('returns an error if user doesn\'t exist', () => {
|
||||
const request = { user: { id: '1234' } };
|
||||
const response = createResponseMock();
|
||||
const response = new Response();
|
||||
|
||||
UserMock
|
||||
.expects('findById')
|
||||
.withArgs('1234')
|
||||
.yields(null, null);
|
||||
|
||||
removeApiKey(request, response);
|
||||
|
||||
expect(User.findById.mock.calls[0][0]).toBe('1234');
|
||||
expect(response.status).toHaveBeenCalledWith(404);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
error: 'User not found'
|
||||
|
@ -140,10 +123,13 @@ describe('user.controller', () => {
|
|||
user: { id: '1234' },
|
||||
params: { keyId: 'not-a-real-key' }
|
||||
};
|
||||
const response = createResponseMock();
|
||||
const response = new Response();
|
||||
const user = new User();
|
||||
|
||||
const user = createUserMock();
|
||||
User.__setFindById(undefined, user);
|
||||
UserMock
|
||||
.expects('findById')
|
||||
.withArgs('1234')
|
||||
.yields(null, user);
|
||||
|
||||
removeApiKey(request, response);
|
||||
|
||||
|
@ -153,27 +139,41 @@ describe('user.controller', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.skip('removes key if it exists', () => {
|
||||
const request = {
|
||||
user: { id: '1234' },
|
||||
params: { keyId: 0 }
|
||||
};
|
||||
const response = createResponseMock();
|
||||
|
||||
const user = createUserMock();
|
||||
|
||||
it('removes key if it exists', () => {
|
||||
const user = new User();
|
||||
user.apiKeys.push({ label: 'first key' }); // id 0
|
||||
user.apiKeys.push({ label: 'second key' }); // id 1
|
||||
|
||||
User.__setFindById(undefined, user);
|
||||
const firstKeyId = user.apiKeys[0]._id.toString();
|
||||
const secondKeyId = user.apiKeys[1]._id.toString();
|
||||
|
||||
const request = {
|
||||
user: { id: '1234' },
|
||||
params: { keyId: firstKeyId }
|
||||
};
|
||||
const response = new Response();
|
||||
|
||||
UserMock
|
||||
.expects('findById')
|
||||
.withArgs('1234')
|
||||
.yields(null, user);
|
||||
|
||||
UserInstanceMock
|
||||
.expects('save')
|
||||
.yields();
|
||||
|
||||
removeApiKey(request, response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(200);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
apiKeys: [
|
||||
{ id: 1, label: 'second key' }
|
||||
]
|
||||
|
||||
const responseData = response.json.mock.calls[0][0];
|
||||
|
||||
expect(responseData.apiKeys.length).toBe(1);
|
||||
expect(responseData.apiKeys[0]).toMatchObject({
|
||||
id: secondKeyId,
|
||||
label: 'second key',
|
||||
lastUsedAt: undefined,
|
||||
createdAt: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,14 +19,20 @@ function generateApiKey() {
|
|||
}
|
||||
|
||||
export function createApiKey(req, res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function sendFailure(code, error) {
|
||||
res.status(code).json({ error });
|
||||
resolve();
|
||||
}
|
||||
|
||||
User.findById(req.user.id, async (err, user) => {
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
sendFailure(404, 'User not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.body.label) {
|
||||
res.status(400).json({ error: 'Expected field \'label\' was not present in request body' });
|
||||
sendFailure(400, 'Expected field \'label\' was not present in request body');
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -36,7 +42,7 @@ export function createApiKey(req, res) {
|
|||
|
||||
user.save((saveErr) => {
|
||||
if (saveErr) {
|
||||
res.status(500).json({ error: saveErr });
|
||||
sendFailure(500, saveErr);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -51,23 +57,33 @@ export function createApiKey(req, res) {
|
|||
});
|
||||
|
||||
res.json({ apiKeys });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function removeApiKey(req, res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function sendFailure(code, error) {
|
||||
res.status(code).json({ error });
|
||||
resolve();
|
||||
}
|
||||
|
||||
User.findById(req.user.id, (err, user) => {
|
||||
if (err) {
|
||||
res.status(500).json({ error: err });
|
||||
sendFailure(500, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
sendFailure(404, 'User not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const keyToDelete = user.apiKeys.find(key => key.id === req.params.keyId);
|
||||
if (!keyToDelete) {
|
||||
res.status(404).json({ error: 'Key does not exist for user' });
|
||||
sendFailure(404, 'Key does not exist for user');
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -75,11 +91,13 @@ export function removeApiKey(req, res) {
|
|||
|
||||
user.save((saveErr) => {
|
||||
if (saveErr) {
|
||||
res.status(500).json({ error: saveErr });
|
||||
sendFailure(500, saveErr);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ apiKeys: user.apiKeys });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
133
server/domain-objects/Project.js
Normal file
133
server/domain-objects/Project.js
Normal file
|
@ -0,0 +1,133 @@
|
|||
import isPlainObject from 'lodash/isPlainObject';
|
||||
import pick from 'lodash/pick';
|
||||
import Project from '../models/project';
|
||||
import createId from '../utils/createId';
|
||||
import createApplicationErrorClass from '../utils/createApplicationErrorClass';
|
||||
import createDefaultFiles from './createDefaultFiles';
|
||||
|
||||
export const FileValidationError = createApplicationErrorClass('FileValidationError');
|
||||
export const ProjectValidationError = createApplicationErrorClass('ProjectValidationError');
|
||||
|
||||
/**
|
||||
* This converts between a mongoose Project model
|
||||
* and the public API Project object properties
|
||||
*
|
||||
*/
|
||||
export function toApi(model) {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a tree of files matching the APIs DirectoryContents
|
||||
* format into the data structure stored in mongodb
|
||||
*
|
||||
* - flattens the tree into an array of file/folders
|
||||
* - each file/folder gets a generated BSON-ID
|
||||
* - each folder has a `children` array containing the IDs of it's children
|
||||
*/
|
||||
function transformFilesInner(tree = {}, parentNode) {
|
||||
const files = [];
|
||||
const errors = [];
|
||||
|
||||
Object.entries(tree).forEach(([name, params]) => {
|
||||
const id = createId();
|
||||
const isFolder = params.files != null;
|
||||
|
||||
if (isFolder) {
|
||||
const folder = {
|
||||
_id: id,
|
||||
name,
|
||||
fileType: 'folder',
|
||||
children: [] // Initialise an empty folder
|
||||
};
|
||||
|
||||
files.push(folder);
|
||||
|
||||
// The recursion will return a list of child files/folders
|
||||
// It will also push the child's id into `folder.children`
|
||||
const subFolder = transformFilesInner(params.files, folder);
|
||||
files.push(...subFolder.files);
|
||||
errors.push(...subFolder.errors);
|
||||
} else {
|
||||
const file = {
|
||||
_id: id,
|
||||
name,
|
||||
fileType: 'file'
|
||||
};
|
||||
|
||||
if (typeof params.url === 'string') {
|
||||
file.url = params.url;
|
||||
} else if (typeof params.content === 'string') {
|
||||
file.content = params.content;
|
||||
} else {
|
||||
errors.push({ name, message: 'missing \'url\' or \'content\'' });
|
||||
}
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
// Push this child's ID onto it's parent's list
|
||||
// of children
|
||||
if (parentNode != null) {
|
||||
parentNode.children.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
return { files, errors };
|
||||
}
|
||||
|
||||
export function transformFiles(tree = {}) {
|
||||
const withRoot = {
|
||||
root: {
|
||||
files: tree
|
||||
}
|
||||
};
|
||||
|
||||
const { files, errors } = transformFilesInner(withRoot);
|
||||
|
||||
if (errors.length > 0) {
|
||||
const message = `${errors.length} files failed validation. See error.files for individual errors.
|
||||
|
||||
Errors:
|
||||
${errors.map(e => `* ${e.name}: ${e.message}`).join('\n')}
|
||||
`;
|
||||
const error = new FileValidationError(message);
|
||||
error.files = errors;
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function containsRootHtmlFile(tree) {
|
||||
return Object.keys(tree).find(name => /\.html$/.test(name)) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This converts between the public API's Project object
|
||||
* properties and a mongoose Project model
|
||||
*
|
||||
*/
|
||||
export function toModel(object) {
|
||||
let files = [];
|
||||
let tree = object.files;
|
||||
|
||||
if (isPlainObject(tree)) {
|
||||
if (!containsRootHtmlFile(tree)) {
|
||||
tree = Object.assign(createDefaultFiles(), tree);
|
||||
}
|
||||
|
||||
files = transformFiles(tree);
|
||||
} else {
|
||||
throw new FileValidationError('\'files\' must be an object');
|
||||
}
|
||||
|
||||
const projectValues = pick(object, ['user', 'name', 'slug']);
|
||||
projectValues.files = files;
|
||||
|
||||
return new Project(projectValues);
|
||||
}
|
385
server/domain-objects/__test__/Project.test.js
Normal file
385
server/domain-objects/__test__/Project.test.js
Normal file
|
@ -0,0 +1,385 @@
|
|||
import find from 'lodash/find';
|
||||
|
||||
import { containsRootHtmlFile, toModel, transformFiles, FileValidationError } from '../Project';
|
||||
|
||||
jest.mock('../../utils/createId');
|
||||
|
||||
// TODO: File name validation
|
||||
// TODO: File extension validation
|
||||
//
|
||||
describe('domain-objects/Project', () => {
|
||||
describe('containsRootHtmlFile', () => {
|
||||
it('returns true for at least one root .html', () => {
|
||||
expect(containsRootHtmlFile({ 'index.html': {} })).toBe(true);
|
||||
expect(containsRootHtmlFile({ 'another-one.html': {} })).toBe(true);
|
||||
expect(containsRootHtmlFile({ 'one.html': {}, 'two.html': {} })).toBe(true);
|
||||
expect(containsRootHtmlFile({ 'one.html': {}, 'sketch.js': {} })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false anything else', () => {
|
||||
expect(containsRootHtmlFile({ 'sketch.js': {} })).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores nested html', () => {
|
||||
expect(containsRootHtmlFile({
|
||||
examples: {
|
||||
files: {
|
||||
'index.html': {}
|
||||
}
|
||||
}
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toModel', () => {
|
||||
it('filters extra properties', () => {
|
||||
const params = {
|
||||
name: 'My sketch',
|
||||
extraThing: 'oopsie',
|
||||
files: {}
|
||||
};
|
||||
|
||||
const model = toModel(params);
|
||||
|
||||
expect(model.name).toBe('My sketch');
|
||||
expect(model.extraThing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws FileValidationError', () => {
|
||||
const params = {
|
||||
files: {
|
||||
'index.html': {} // missing content or url
|
||||
}
|
||||
};
|
||||
|
||||
expect(() => toModel(params)).toThrowError(FileValidationError);
|
||||
});
|
||||
|
||||
it('throws if files is not an object', () => {
|
||||
const params = {
|
||||
files: []
|
||||
};
|
||||
|
||||
expect(() => toModel(params)).toThrowError(FileValidationError);
|
||||
});
|
||||
|
||||
it('creates default index.html and dependent files if no root .html is provided', () => {
|
||||
const params = {
|
||||
files: {}
|
||||
};
|
||||
|
||||
const { files } = toModel(params);
|
||||
|
||||
expect(files.length).toBe(4);
|
||||
expect(find(files, { name: 'index.html' })).not.toBeUndefined();
|
||||
expect(find(files, { name: 'sketch.js' })).not.toBeUndefined();
|
||||
expect(find(files, { name: 'style.css' })).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not create default files if any root .html is provided', () => {
|
||||
const params = {
|
||||
files: {
|
||||
'example.html': {
|
||||
content: '<html><body>Hello!</body></html>'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { files } = toModel(params);
|
||||
|
||||
expect(files.length).toBe(2);
|
||||
expect(find(files, { name: 'example.html' })).not.toBeUndefined();
|
||||
expect(find(files, { name: 'index.html' })).toBeUndefined();
|
||||
expect(find(files, { name: 'sketch.js' })).toBeUndefined();
|
||||
expect(find(files, { name: 'style.css' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not overwrite default CSS and JS of the same name if provided', () => {
|
||||
const params = {
|
||||
files: {
|
||||
'sketch.js': {
|
||||
content: 'const sketch = true;'
|
||||
},
|
||||
'style.css': {
|
||||
content: 'body { outline: 10px solid red; }'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { files } = toModel(params);
|
||||
|
||||
expect(files.length).toBe(4);
|
||||
expect(find(files, { name: 'index.html' })).not.toBeUndefined();
|
||||
|
||||
const sketchFile = find(files, { name: 'sketch.js' });
|
||||
expect(sketchFile.content).toBe('const sketch = true;');
|
||||
|
||||
const cssFile = find(files, { name: 'style.css' });
|
||||
expect(cssFile.content).toBe('body { outline: 10px solid red; }');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformFiles', () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line global-require
|
||||
const { resetMockCreateId } = require('../../utils/createId');
|
||||
|
||||
resetMockCreateId();
|
||||
});
|
||||
|
||||
it('creates an empty root with no data', () => {
|
||||
const tree = {};
|
||||
|
||||
expect(transformFiles(tree)).toEqual([{
|
||||
_id: '0',
|
||||
fileType: 'folder',
|
||||
name: 'root',
|
||||
children: []
|
||||
}]);
|
||||
});
|
||||
|
||||
it('converts tree-shaped files into list', () => {
|
||||
const tree = {
|
||||
'index.html': {
|
||||
content: 'some contents',
|
||||
}
|
||||
};
|
||||
|
||||
expect(transformFiles(tree)).toEqual([
|
||||
{
|
||||
_id: '0',
|
||||
fileType: 'folder',
|
||||
name: 'root',
|
||||
children: ['1']
|
||||
},
|
||||
{
|
||||
_id: '1',
|
||||
content: 'some contents',
|
||||
fileType: 'file',
|
||||
name: 'index.html'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses file url over content', () => {
|
||||
const tree = {
|
||||
'script.js': {
|
||||
url: 'http://example.net/something.js'
|
||||
}
|
||||
};
|
||||
|
||||
expect(transformFiles(tree)).toEqual([
|
||||
{
|
||||
_id: '0',
|
||||
fileType: 'folder',
|
||||
name: 'root',
|
||||
children: ['1']
|
||||
},
|
||||
{
|
||||
_id: '1',
|
||||
url: 'http://example.net/something.js',
|
||||
fileType: 'file',
|
||||
name: 'script.js'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates folders', () => {
|
||||
const tree = {
|
||||
'a-folder': {
|
||||
files: {}
|
||||
},
|
||||
};
|
||||
|
||||
expect(transformFiles(tree)).toEqual([
|
||||
{
|
||||
_id: '0',
|
||||
fileType: 'folder',
|
||||
name: 'root',
|
||||
children: ['1']
|
||||
},
|
||||
{
|
||||
_id: '1',
|
||||
children: [],
|
||||
fileType: 'folder',
|
||||
name: 'a-folder'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('walks the tree processing files', () => {
|
||||
const tree = {
|
||||
'index.html': {
|
||||
content: 'some contents',
|
||||
},
|
||||
'a-folder': {
|
||||
files: {
|
||||
'data.csv': {
|
||||
content: 'this,is,data'
|
||||
},
|
||||
'another.txt': {
|
||||
content: 'blah blah'
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
expect(transformFiles(tree)).toEqual([
|
||||
{
|
||||
_id: '0',
|
||||
fileType: 'folder',
|
||||
name: 'root',
|
||||
children: ['1', '2']
|
||||
},
|
||||
{
|
||||
_id: '1',
|
||||
name: 'index.html',
|
||||
fileType: 'file',
|
||||
content: 'some contents'
|
||||
},
|
||||
{
|
||||
_id: '2',
|
||||
name: 'a-folder',
|
||||
fileType: 'folder',
|
||||
children: ['3', '4']
|
||||
},
|
||||
{
|
||||
_id: '3',
|
||||
name: 'data.csv',
|
||||
fileType: 'file',
|
||||
content: 'this,is,data'
|
||||
},
|
||||
{
|
||||
_id: '4',
|
||||
name: 'another.txt',
|
||||
fileType: 'file',
|
||||
content: 'blah blah'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles deep nesting', () => {
|
||||
const tree = {
|
||||
first: {
|
||||
files: {
|
||||
second: {
|
||||
files: {
|
||||
third: {
|
||||
files: {
|
||||
'hello.js': {
|
||||
content: 'world!'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
expect(transformFiles(tree)).toEqual([
|
||||
{
|
||||
_id: '0',
|
||||
fileType: 'folder',
|
||||
name: 'root',
|
||||
children: ['1']
|
||||
},
|
||||
{
|
||||
_id: '1',
|
||||
name: 'first',
|
||||
fileType: 'folder',
|
||||
children: ['2']
|
||||
},
|
||||
{
|
||||
_id: '2',
|
||||
name: 'second',
|
||||
fileType: 'folder',
|
||||
children: ['3']
|
||||
},
|
||||
{
|
||||
_id: '3',
|
||||
name: 'third',
|
||||
fileType: 'folder',
|
||||
children: ['4']
|
||||
},
|
||||
{
|
||||
_id: '4',
|
||||
name: 'hello.js',
|
||||
fileType: 'file',
|
||||
content: 'world!'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('allows duplicate names in different folder', () => {
|
||||
const tree = {
|
||||
'index.html': {
|
||||
content: 'some contents',
|
||||
},
|
||||
'data': {
|
||||
files: {
|
||||
'index.html': {
|
||||
content: 'different file'
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
expect(transformFiles(tree)).toEqual([
|
||||
{
|
||||
_id: '0',
|
||||
fileType: 'folder',
|
||||
name: 'root',
|
||||
children: ['1', '2']
|
||||
},
|
||||
{
|
||||
_id: '1',
|
||||
name: 'index.html',
|
||||
fileType: 'file',
|
||||
content: 'some contents'
|
||||
},
|
||||
{
|
||||
_id: '2',
|
||||
name: 'data',
|
||||
fileType: 'folder',
|
||||
children: ['3']
|
||||
},
|
||||
{
|
||||
_id: '3',
|
||||
name: 'index.html',
|
||||
fileType: 'file',
|
||||
content: 'different file'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates files', () => {
|
||||
const tree = {
|
||||
'index.html': {} // missing `content`
|
||||
};
|
||||
|
||||
expect(() => transformFiles(tree)).toThrowError(FileValidationError);
|
||||
});
|
||||
|
||||
it('collects all file validation errors', () => {
|
||||
const tree = {
|
||||
'index.html': {}, // missing `content`
|
||||
'something.js': {} // also missing `content`
|
||||
};
|
||||
|
||||
try {
|
||||
transformFiles(tree);
|
||||
|
||||
// Should not get here
|
||||
throw new Error('should have thrown before this point');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FileValidationError);
|
||||
expect(err.files).toEqual([
|
||||
{ name: 'index.html', message: 'missing \'url\' or \'content\'' },
|
||||
{ name: 'something.js', message: 'missing \'url\' or \'content\'' }
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
48
server/domain-objects/createDefaultFiles.js
Normal file
48
server/domain-objects/createDefaultFiles.js
Normal file
|
@ -0,0 +1,48 @@
|
|||
const defaultSketch = `function setup() {
|
||||
createCanvas(400, 400);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
background(220);
|
||||
}`;
|
||||
|
||||
const defaultHTML =
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.8.0/p5.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.8.0/addons/p5.dom.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.8.0/addons/p5.sound.min.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="style.css">
|
||||
<meta charset="utf-8" />
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<script src="sketch.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const defaultCSS =
|
||||
`html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function createDefaultFiles() {
|
||||
return {
|
||||
'index.html': {
|
||||
content: defaultHTML
|
||||
},
|
||||
'style.css': {
|
||||
content: defaultCSS
|
||||
},
|
||||
'sketch.js': {
|
||||
content: defaultSketch
|
||||
}
|
||||
};
|
||||
}
|
|
@ -11,6 +11,24 @@ export function createMock() {
|
|||
return sinon.mock(Project);
|
||||
}
|
||||
|
||||
// Wraps the Project.prototype i.e. the
|
||||
// instance methods in a mock so
|
||||
// Project.save() can be mocked
|
||||
export function createInstanceMock() {
|
||||
// See: https://stackoverflow.com/questions/40962960/sinon-mock-of-mongoose-save-method-for-all-future-instances-of-a-model-with-pro
|
||||
Object.defineProperty(Project.prototype, 'save', {
|
||||
value: Project.prototype.save,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(Project.prototype, 'remove', {
|
||||
value: Project.prototype.remove,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return sinon.mock(Project.prototype);
|
||||
}
|
||||
|
||||
// Re-export the model, it will be
|
||||
// altered by mockingoose whenever
|
||||
// we call methods on the MockConfig
|
||||
|
|
|
@ -1,12 +1,31 @@
|
|||
let __err = null;
|
||||
let __user = null;
|
||||
import sinon from 'sinon';
|
||||
import 'sinon-mongoose';
|
||||
|
||||
export default {
|
||||
__setFindById(err, user) {
|
||||
__err = err;
|
||||
__user = user;
|
||||
},
|
||||
findById: jest.fn(async (id, callback) => {
|
||||
callback(__err, __user);
|
||||
})
|
||||
};
|
||||
// Import the actual model to be mocked
|
||||
const User = jest.requireActual('../user').default;
|
||||
|
||||
// Wrap User in a sinon mock
|
||||
// The returned object is used to configure
|
||||
// the mocked model's behaviour
|
||||
export function createMock() {
|
||||
return sinon.mock(User);
|
||||
}
|
||||
|
||||
// Wraps the User.prototype i.e. the
|
||||
// instance methods in a mock so
|
||||
// User.save() can be mocked
|
||||
export function createInstanceMock() {
|
||||
// See: https://stackoverflow.com/questions/40962960/sinon-mock-of-mongoose-save-method-for-all-future-instances-of-a-model-with-pro
|
||||
Object.defineProperty(User.prototype, 'save', {
|
||||
value: User.prototype.save,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return sinon.mock(User.prototype);
|
||||
}
|
||||
|
||||
|
||||
// Re-export the model, it will be
|
||||
// altered by mockingoose whenever
|
||||
// we call methods on the MockConfig
|
||||
export default User;
|
||||
|
|
|
@ -49,8 +49,43 @@ projectSchema.set('toJSON', {
|
|||
|
||||
projectSchema.pre('save', function generateSlug(next) {
|
||||
const project = this;
|
||||
|
||||
if (!project.slug) {
|
||||
project.slug = slugify(project.name, '_');
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if slug is unique for this user's projects
|
||||
*/
|
||||
projectSchema.methods.isSlugUnique = async function isSlugUnique(cb) {
|
||||
const project = this;
|
||||
const hasCallback = typeof cb === 'function';
|
||||
|
||||
try {
|
||||
const docsWithSlug = await project.model('Project')
|
||||
.find({ user: project.user, slug: project.slug }, '_id')
|
||||
.exec();
|
||||
|
||||
const result = {
|
||||
isUnique: docsWithSlug.length === 0,
|
||||
conflictingIds: docsWithSlug.map(d => d._id) || []
|
||||
};
|
||||
|
||||
if (hasCallback) {
|
||||
cb(null, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (hasCallback) {
|
||||
cb(err, null);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export default mongoose.model('Project', projectSchema);
|
||||
|
|
27
server/routes/api.routes.js
Normal file
27
server/routes/api.routes.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Router } from 'express';
|
||||
import passport from 'passport';
|
||||
import * as ProjectController from '../controllers/project.controller';
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get(
|
||||
'/:username/sketches',
|
||||
passport.authenticate('basic', { session: false }),
|
||||
ProjectController.apiGetProjectsForUser
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:username/sketches',
|
||||
passport.authenticate('basic', { session: false }),
|
||||
ProjectController.apiCreateProject
|
||||
);
|
||||
|
||||
// NOTE: Currently :username will not be checked for ownership
|
||||
// only the project's owner in the database.
|
||||
router.delete(
|
||||
'/:username/sketches/:project_id',
|
||||
passport.authenticate('basic', { session: false }),
|
||||
ProjectController.deleteProject
|
||||
);
|
||||
|
||||
export default router;
|
|
@ -1,11 +1,7 @@
|
|||
import fs from 'fs';
|
||||
import rp from 'request-promise';
|
||||
import Q from 'q';
|
||||
import mongoose from 'mongoose';
|
||||
import objectID from 'bson-objectid';
|
||||
import shortid from 'shortid';
|
||||
import User from '../models/user';
|
||||
import Project from '../models/project';
|
||||
import { ok } from 'assert';
|
||||
|
||||
// TODO: Change branchName if necessary
|
||||
const branchName = 'release';
|
||||
|
@ -14,11 +10,20 @@ const baseUrl = 'https://api.github.com/repos/ml5js/ml5-examples/contents';
|
|||
const clientId = process.env.GITHUB_ID;
|
||||
const clientSecret = process.env.GITHUB_SECRET;
|
||||
const editorUsername = process.env.ML5_EXAMPLES_USERNAME;
|
||||
const personalAccessToken = process.env.EDITOR_API_ACCESS_TOKEN;
|
||||
const editorApiUrl = process.env.EDITOR_API_URL;
|
||||
const headers = {
|
||||
'User-Agent': 'p5js-web-editor/0.0.1'
|
||||
};
|
||||
|
||||
const requestOptions = {
|
||||
ok(clientId, 'GITHUB_ID is required');
|
||||
ok(clientSecret, 'GITHUB_SECRET is required');
|
||||
ok(editorUsername, 'ML5_EXAMPLES_USERNAME is required');
|
||||
ok(personalAccessToken, 'EDITOR_API_ACCESS_TOKEN is required');
|
||||
ok(editorApiUrl, 'EDITOR_API_URL is required');
|
||||
|
||||
//
|
||||
const githubRequestOptions = {
|
||||
url: baseUrl,
|
||||
qs: {
|
||||
client_id: clientId,
|
||||
|
@ -29,14 +34,15 @@ const requestOptions = {
|
|||
json: true
|
||||
};
|
||||
|
||||
const mongoConnectionString = process.env.MONGO_URL;
|
||||
mongoose.connect(mongoConnectionString, {
|
||||
useMongoClient: true
|
||||
});
|
||||
mongoose.connection.on('error', () => {
|
||||
console.error('MongoDB Connection Error. Please make sure that MongoDB is running.');
|
||||
process.exit(1);
|
||||
});
|
||||
const editorRequestOptions = {
|
||||
url: `${editorApiUrl}/${editorUsername}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...headers,
|
||||
Authorization: `Basic ${Buffer.from(`${editorUsername}:${personalAccessToken}`).toString('base64')}`
|
||||
},
|
||||
json: true
|
||||
};
|
||||
|
||||
/**
|
||||
* ---------------------------------------------------------
|
||||
|
@ -51,12 +57,52 @@ function flatten(list) {
|
|||
return list.reduce((a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data for a single HTML/JS file, or return
|
||||
* an url to the file's CDN location
|
||||
*/
|
||||
async function fetchFileContent(item) {
|
||||
const { name } = item;
|
||||
const file = { url: item.url };
|
||||
|
||||
// if it is an html or js file
|
||||
if (
|
||||
(file.url != null && name.endsWith('.html')) ||
|
||||
name.endsWith('.js')
|
||||
) {
|
||||
const options = Object.assign({}, githubRequestOptions);
|
||||
options.url = `${file.url}`;
|
||||
|
||||
if (
|
||||
options.url !== undefined ||
|
||||
options.url !== null ||
|
||||
options.url !== ''
|
||||
) {
|
||||
file.content = await rp(options);
|
||||
// NOTE: remove the URL property if there's content
|
||||
// Otherwise the p5 editor will try to pull from that url
|
||||
if (file.content !== null) delete file.url;
|
||||
}
|
||||
|
||||
return file;
|
||||
// if it is NOT an html or js file
|
||||
}
|
||||
|
||||
if (file.url) {
|
||||
const cdnRef = `https://cdn.jsdelivr.net/gh/ml5js/ml5-examples@${branchName}${file.url.split(branchName)[1]}`;
|
||||
file.url = cdnRef;
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* STEP 1: Get the top level cateogories
|
||||
*/
|
||||
async function getCategories() {
|
||||
try {
|
||||
const options = Object.assign({}, requestOptions);
|
||||
const options = Object.assign({}, githubRequestOptions);
|
||||
options.url = `${options.url}/p5js${branchRef}`;
|
||||
const results = await rp(options);
|
||||
|
||||
|
@ -76,13 +122,18 @@ async function getCategoryExamples(sketchRootList) {
|
|||
const output = [];
|
||||
const sketchRootCategories = sketchRootList.map(async (categories) => {
|
||||
// let options = Object.assign({url: `${requestOptions.url}/${categories.path}${branchRef}`}, requestOptions)
|
||||
const options = Object.assign({}, requestOptions);
|
||||
const options = Object.assign({}, githubRequestOptions);
|
||||
options.url = `${options.url}${categories.path}${branchRef}`;
|
||||
// console.log(options)
|
||||
const sketchDirs = await rp(options);
|
||||
|
||||
try {
|
||||
const result = flatten(sketchDirs);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const sketchList = await Q.all(sketchRootCategories);
|
||||
|
@ -107,7 +158,7 @@ async function traverseSketchTree(parentObject) {
|
|||
return output;
|
||||
}
|
||||
// let options = `https://api.github.com/repos/ml5js/ml5-examples/contents/${sketches.path}${branchRef}`
|
||||
const options = Object.assign({}, requestOptions);
|
||||
const options = Object.assign({}, githubRequestOptions);
|
||||
options.url = `${options.url}${parentObject.path}${branchRef}`;
|
||||
|
||||
output.tree = await rp(options);
|
||||
|
@ -124,7 +175,6 @@ async function traverseSketchTree(parentObject) {
|
|||
* @param {*} categoryExamples - all of the categories in an array
|
||||
*/
|
||||
async function traverseSketchTreeAll(categoryExamples) {
|
||||
// const sketches = categoryExamples.map(async sketch => await traverseSketchTree(sketch));
|
||||
const sketches = categoryExamples.map(async sketch => traverseSketchTree(sketch));
|
||||
|
||||
const result = await Q.all(sketches);
|
||||
|
@ -139,37 +189,24 @@ function traverseAndFormat(parentObject) {
|
|||
const parent = Object.assign({}, parentObject);
|
||||
|
||||
if (!parentObject.tree) {
|
||||
const newid = objectID().toHexString();
|
||||
// returns the files
|
||||
return {
|
||||
name: parent.name,
|
||||
url: parent.download_url,
|
||||
content: null,
|
||||
id: newid,
|
||||
_id: newid,
|
||||
fileType: 'file'
|
||||
url: parent.download_url
|
||||
};
|
||||
}
|
||||
|
||||
const subdir = parentObject.tree.map((item) => {
|
||||
const newid = objectID().toHexString();
|
||||
if (!item.tree) {
|
||||
// returns the files
|
||||
return {
|
||||
name: item.name,
|
||||
url: item.download_url,
|
||||
content: null,
|
||||
id: newid,
|
||||
_id: newid,
|
||||
fileType: 'file'
|
||||
url: item.download_url
|
||||
};
|
||||
}
|
||||
|
||||
const feat = {
|
||||
name: item.name,
|
||||
id: newid,
|
||||
_id: newid,
|
||||
fileType: 'folder',
|
||||
children: traverseAndFormat(item)
|
||||
};
|
||||
return feat;
|
||||
|
@ -178,69 +215,27 @@ function traverseAndFormat(parentObject) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Traverse the tree and flatten for project.files[]
|
||||
* Traverse the tree and download all the content,
|
||||
* transforming into an object keyed by file/directory name
|
||||
* @param {*} projectFileTree
|
||||
*/
|
||||
function traverseAndFlatten(projectFileTree) {
|
||||
const r = objectID().toHexString();
|
||||
async function traverseAndDownload(projectFileTree) {
|
||||
return projectFileTree.reduce(
|
||||
async (previousPromise, item, idx) => {
|
||||
const result = await previousPromise;
|
||||
|
||||
const projectRoot = {
|
||||
name: 'root',
|
||||
id: r,
|
||||
_id: r,
|
||||
children: [],
|
||||
fileType: 'folder'
|
||||
if (Array.isArray(item.children)) {
|
||||
result[item.name] = {
|
||||
files: await traverseAndDownload(item.children)
|
||||
};
|
||||
|
||||
let currentParent;
|
||||
|
||||
const output = projectFileTree.reduce(
|
||||
(result, item, idx) => {
|
||||
if (idx < projectFileTree.length) {
|
||||
projectRoot.children.push(item.id);
|
||||
}
|
||||
|
||||
if (item.fileType === 'file') {
|
||||
if (item.name === 'sketch.js') {
|
||||
item.isSelectedFile = true;
|
||||
}
|
||||
result.push(item);
|
||||
}
|
||||
|
||||
// here's where the magic happens *twinkles*
|
||||
if (item.fileType === 'folder') {
|
||||
// recursively go down the tree of children
|
||||
currentParent = traverseAndFlatten(item.children);
|
||||
// the above will return an array of the children files
|
||||
// concatenate that with the results
|
||||
result = result.concat(currentParent); // eslint-disable-line no-param-reassign
|
||||
// since we want to get the children ids,
|
||||
// we can map the child ids to the current item
|
||||
// then push that to our result array to get
|
||||
// our flat files array.
|
||||
item.children = item.children.map(child => child.id);
|
||||
result.push(item);
|
||||
} else {
|
||||
result[item.name] = await fetchFileContent(item);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
[projectRoot]
|
||||
{}
|
||||
);
|
||||
|
||||
// Kind of hacky way to remove all roots other than the starting one
|
||||
let counter = 0;
|
||||
output.forEach((item, idx) => {
|
||||
if (item.name === 'root') {
|
||||
if (counter === 0) {
|
||||
counter += 1;
|
||||
} else {
|
||||
output.splice(idx, 1);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -249,16 +244,14 @@ function traverseAndFlatten(projectFileTree) {
|
|||
* @param {*} sketch
|
||||
* @param {*} user
|
||||
*/
|
||||
function formatSketchForStorage(sketch, user) {
|
||||
const newProject = new Project({
|
||||
_id: shortid.generate(),
|
||||
async function formatSketchForStorage(sketch, user) {
|
||||
const newProject = {
|
||||
name: sketch.name,
|
||||
user: user._id,
|
||||
files: [] // <== add files to this array as file objects and add _id reference to children of root
|
||||
});
|
||||
files: {} // <== add files to this object
|
||||
};
|
||||
|
||||
let projectFiles = traverseAndFormat(sketch);
|
||||
projectFiles = traverseAndFlatten(projectFiles);
|
||||
projectFiles = await traverseAndDownload(projectFiles);
|
||||
newProject.files = projectFiles;
|
||||
return newProject;
|
||||
}
|
||||
|
@ -271,68 +264,50 @@ function formatSketchForStorageAll(sketchWithItems, user) {
|
|||
|
||||
sketchList = sketchList.map(sketch => formatSketchForStorage(sketch, user));
|
||||
|
||||
return sketchList;
|
||||
return Promise.all(sketchList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the content for the relevant files in project.files[]
|
||||
* @param {*} projectObject
|
||||
* Fetch a list of all projects from the API
|
||||
*/
|
||||
async function fetchSketchContent(projectObject) {
|
||||
const output = Object.assign({}, JSON.parse(JSON.stringify(projectObject)));
|
||||
async function getProjectsList() {
|
||||
const options = Object.assign({}, editorRequestOptions);
|
||||
options.url = `${options.url}/sketches`;
|
||||
|
||||
const newFiles = output.files.map(async (item, i) => {
|
||||
// if it is an html or js file
|
||||
if (
|
||||
(item.fileType === 'file' && item.name.endsWith('.html')) ||
|
||||
item.name.endsWith('.js')
|
||||
) {
|
||||
const options = Object.assign({}, requestOptions);
|
||||
options.url = `${item.url}`;
|
||||
const results = await rp(options);
|
||||
|
||||
if (
|
||||
options.url !== undefined ||
|
||||
options.url !== null ||
|
||||
options.url !== ''
|
||||
) {
|
||||
item.content = await rp(options);
|
||||
// NOTE: remove the URL property if there's content
|
||||
// Otherwise the p5 editor will try to pull from that url
|
||||
if (item.content !== null) delete item.url;
|
||||
}
|
||||
|
||||
return item;
|
||||
// if it is NOT an html or js file
|
||||
}
|
||||
|
||||
if (item.url) {
|
||||
const cdnRef = `https://cdn.jsdelivr.net/gh/ml5js/ml5-examples@${branchName}${
|
||||
item.url.split(branchName)[1]
|
||||
}`;
|
||||
item.content = cdnRef;
|
||||
item.url = cdnRef;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
output.files = await Q.all(newFiles);
|
||||
return output;
|
||||
return results.sketches;
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 5
|
||||
* Get all the content for the relevant files in project.files[] for all sketches
|
||||
* @param {*} formattedSketchList
|
||||
* Delete a project
|
||||
*/
|
||||
async function fetchSketchContentAll(formattedSketchList) {
|
||||
let output = formattedSketchList.slice(0);
|
||||
async function deleteProject(project) {
|
||||
const options = Object.assign({}, editorRequestOptions);
|
||||
options.method = 'DELETE';
|
||||
options.url = `${options.url}/sketches/${project.id}`;
|
||||
|
||||
output = output.map(async item => fetchSketchContent(item));
|
||||
const results = await rp(options);
|
||||
|
||||
output = await Q.all(output);
|
||||
return results;
|
||||
}
|
||||
|
||||
return output;
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
async function createProject(project) {
|
||||
try {
|
||||
const options = Object.assign({}, editorRequestOptions);
|
||||
options.method = 'POST';
|
||||
options.url = `${options.url}/sketches`;
|
||||
options.body = project;
|
||||
|
||||
const results = await rp(options);
|
||||
|
||||
return results;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -342,43 +317,34 @@ async function fetchSketchContentAll(formattedSketchList) {
|
|||
* @param {*} user
|
||||
*/
|
||||
async function createProjectsInP5User(filledProjectList, user) {
|
||||
const userProjects = await Project.find({ user: user._id });
|
||||
const removeProjects = userProjects.map(async project => Project.remove({ _id: project._id }));
|
||||
await Q.all(removeProjects);
|
||||
console.log('deleted old projects!');
|
||||
console.log('Finding existing projects...');
|
||||
|
||||
const existingProjects = await getProjectsList();
|
||||
|
||||
console.log(`Will delete ${existingProjects.length} projects`);
|
||||
|
||||
try {
|
||||
await Q.all(existingProjects.map(deleteProject));
|
||||
console.log('deleted old projects!');
|
||||
} catch (error) {
|
||||
console.log('Problem deleting projects');
|
||||
console.log(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const newProjects = filledProjectList.map(async (project) => {
|
||||
const item = new Project(project);
|
||||
console.log(`saving ${project.name}`);
|
||||
await item.save();
|
||||
await createProject(project);
|
||||
});
|
||||
await Q.all(newProjects);
|
||||
console.log(`Projects saved to User: ${editorUsername}!`);
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 0
|
||||
* CHECK if user exists, ifnot create one
|
||||
*
|
||||
*/
|
||||
async function checkP5User() {
|
||||
const user = await User.findOne({ username: editorUsername });
|
||||
|
||||
if (!user) {
|
||||
const ml5user = new User({
|
||||
username: editorUsername,
|
||||
email: process.env.ML5_EXAMPLES_EMAIL,
|
||||
password: process.env.ML5_EXAMPLES_PASS
|
||||
});
|
||||
|
||||
await ml5user.save((saveErr) => {
|
||||
if (saveErr) throw saveErr;
|
||||
console.log(`Created a user p5${ml5user}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Error saving projects');
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ---------------------------------------------------------
|
||||
* --------------------- main ------------------------------
|
||||
|
@ -394,18 +360,14 @@ async function checkP5User() {
|
|||
* Delete existing and save
|
||||
*/
|
||||
async function make() {
|
||||
await checkP5User();
|
||||
// Get the user
|
||||
const user = await User.findOne({
|
||||
username: editorUsername
|
||||
});
|
||||
// Get the categories and their examples
|
||||
const categories = await getCategories();
|
||||
const categoryExamples = await getCategoryExamples(categories);
|
||||
|
||||
const examplesWithResourceTree = await traverseSketchTreeAll(categoryExamples);
|
||||
const formattedSketchList = formatSketchForStorageAll(examplesWithResourceTree, user);
|
||||
const filledProjectList = await fetchSketchContentAll(formattedSketchList);
|
||||
await createProjectsInP5User(filledProjectList, user);
|
||||
const formattedSketchList = await formatSketchForStorageAll(examplesWithResourceTree);
|
||||
|
||||
await createProjectsInP5User(formattedSketchList);
|
||||
console.log('done!');
|
||||
process.exit();
|
||||
}
|
||||
|
@ -418,20 +380,14 @@ async function make() {
|
|||
* Format the sketch files to be save to the db
|
||||
* Delete existing and save
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async function test() {
|
||||
await checkP5User();
|
||||
// Get the user
|
||||
const user = await User.findOne({
|
||||
username: editorUsername
|
||||
});
|
||||
|
||||
// read from file while testing
|
||||
const examplesWithResourceTree = JSON.parse(fs.readFileSync('./ml5-examplesWithResourceTree.json'));
|
||||
|
||||
const formattedSketchList = formatSketchForStorageAll(examplesWithResourceTree, user);
|
||||
const formattedSketchList = await formatSketchForStorageAll(examplesWithResourceTree);
|
||||
|
||||
const filledProjectList = await fetchSketchContentAll(formattedSketchList);
|
||||
await createProjectsInP5User(filledProjectList, user);
|
||||
await createProjectsInP5User(formattedSketchList);
|
||||
console.log('done!');
|
||||
process.exit();
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import webpackHotMiddleware from 'webpack-hot-middleware';
|
|||
import config from '../webpack/config.dev';
|
||||
|
||||
// Import all required modules
|
||||
import api from './routes/api.routes';
|
||||
import users from './routes/user.routes';
|
||||
import sessions from './routes/session.routes';
|
||||
import projects from './routes/project.routes';
|
||||
|
@ -51,7 +52,7 @@ const corsOriginsWhitelist = [
|
|||
// Run Webpack dev server in development mode
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const compiler = webpack(config);
|
||||
app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config[0].output.publicPath }));
|
||||
app.use(webpackDevMiddleware(compiler, { lazy: false, noInfo: true, publicPath: config[0].output.publicPath }));
|
||||
app.use(webpackHotMiddleware(compiler));
|
||||
|
||||
corsOriginsWhitelist.push(/localhost/);
|
||||
|
@ -95,6 +96,7 @@ app.use(session({
|
|||
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
app.use('/api/v1', requestsOfTypeJSON(), api);
|
||||
app.use('/api', requestsOfTypeJSON(), users);
|
||||
app.use('/api', requestsOfTypeJSON(), sessions);
|
||||
app.use('/api', requestsOfTypeJSON(), files);
|
||||
|
|
16
server/utils/__mocks__/createId.js
Normal file
16
server/utils/__mocks__/createId.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Creates an increasing numeric ID
|
||||
* for testing
|
||||
*/
|
||||
let nextId = 0;
|
||||
|
||||
export default function mockCreateId() {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
|
||||
return String(id);
|
||||
}
|
||||
|
||||
export function resetMockCreateId() {
|
||||
nextId = 0;
|
||||
}
|
33
server/utils/createApplicationErrorClass.js
Normal file
33
server/utils/createApplicationErrorClass.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* This is the base class for custom errors in
|
||||
* the application.
|
||||
*/
|
||||
export class ApplicationError extends Error {
|
||||
constructor(message, extra = {}) {
|
||||
super();
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
this.name = 'ApplicationError';
|
||||
this.message = message;
|
||||
this.code = extra.code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new custom error class e.g.
|
||||
* const UserNotFoundError = createApplicationErrorClass('UserNotFoundError');
|
||||
*
|
||||
* // Later
|
||||
* throw new UserNotFoundError(`user ${user.name} not found`);
|
||||
*
|
||||
*/
|
||||
export default function createApplicationErrorClass(name) {
|
||||
return class extends ApplicationError {
|
||||
constructor(...params) {
|
||||
super(...params);
|
||||
|
||||
this.name = name;
|
||||
}
|
||||
};
|
||||
}
|
8
server/utils/createId.js
Normal file
8
server/utils/createId.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import objectID from 'bson-objectid';
|
||||
|
||||
/**
|
||||
* Creates a mongo ID
|
||||
*/
|
||||
export default function createId() {
|
||||
return objectID().toHexString();
|
||||
}
|
Loading…
Reference in a new issue