diff --git a/.meteor/packages b/.meteor/packages index ccc93d8..6bc6b6d 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -20,3 +20,5 @@ shell-server@0.2.3 # Server-side component of the `meteor shell` comm accounts-password session fourseven:scss +practicalmeteor:mocha +react-meteor-data diff --git a/.meteor/versions b/.meteor/versions index 66ab2f0..e696338 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -14,6 +14,7 @@ caching-compiler@1.1.9 caching-html-compiler@1.1.2 callback-hook@1.0.10 check@1.2.5 +coffeescript@1.0.17 ddp@1.2.5 ddp-client@1.3.4 ddp-common@1.2.8 @@ -54,9 +55,15 @@ npm-bcrypt@0.9.2 npm-mongo@2.2.24 observe-sequence@1.0.16 ordered-dict@1.0.9 +practicalmeteor:chai@2.1.0_1 +practicalmeteor:loglevel@1.2.0_2 +practicalmeteor:mocha@2.4.5_6 +practicalmeteor:mocha-core@1.0.1 +practicalmeteor:sinon@1.14.1_2 promise@0.8.8 random@1.0.10 rate-limit@1.0.8 +react-meteor-data@0.2.11 reactive-dict@1.1.8 reactive-var@1.0.11 reload@1.1.11 @@ -75,6 +82,8 @@ templating@1.3.2 templating-compiler@1.3.2 templating-runtime@1.3.2 templating-tools@1.1.2 +tmeasday:check-npm-versions@0.2.0 +tmeasday:test-reporter-helpers@0.2.1 tracker@1.1.3 ui@1.0.13 underscore@1.0.10 diff --git a/client/main.html b/client/main.html index 46c2621..3706ac5 100644 --- a/client/main.html +++ b/client/main.html @@ -1,5 +1,5 @@ - Meteor BoilerPlate + Todo Apps diff --git a/import/api/notes.js b/import/api/notes.js new file mode 100644 index 0000000..6e8ddfc --- /dev/null +++ b/import/api/notes.js @@ -0,0 +1,74 @@ +import {Meteor} from 'meteor/meteor' +import {Mongo} from 'meteor/mongo' + +import moment from 'moment' +import SimpleSchema from 'simpl-schema'; + +export const Notes = new Mongo.Collection('notes') + +if(Meteor.isServer){ + Meteor.publish('notes', function(){ + return Notes.find({userId:this.userId}) + }) +} + +Meteor.methods({ + 'notes.insert'(){ + if(!this.userId){ + throw new Meteor.Error('not-authenticated') + } + return Notes.insert({ + title:'', + body:'', + userId: this.userId, + updatedAt: moment().valueOf() + }) + }, + + 'notes.remove'(_id){ + if(!this.userId){ + throw new Meteor.Error('not-authenticated') + } + try{ + new SimpleSchema({ + _id:{ + type:String, + min:1 + } + }).validate({_id}) + }catch(e){ + console.log('e',e,e.message); + throw new Meteor.Error(400, e.message) + } + + Notes.remove({_id, userId:this.userId}); + }, + 'notes.update'(_id, updates){ + if(!this.userId){ + throw new Meteor.Error('not-authenticated') + } + + try{ + new SimpleSchema({ + _id:{ + type:String, + min:1 + }, + title:{ + type:String, + optional:true + }, + body:{ + type:String, + optional:true + } + }).validate({_id, ...updates}) + }catch(e){ + console.log('e',e,e.message); + throw new Meteor.Error(400, e.message) + } + + Notes.update({_id, userId:this.userId },{$set:{updatedAt: moment().valueOf(), ...updates}}) + + } +}) diff --git a/import/api/notes.test.js b/import/api/notes.test.js new file mode 100644 index 0000000..2c0f64c --- /dev/null +++ b/import/api/notes.test.js @@ -0,0 +1,116 @@ +import {Meteor} from 'meteor/meteor' +import expect from 'expect'; + +import {Notes} from './notes' + +if(Meteor.isServer){ + describe('notes',function(){ + + const noteOne = { + _id:'testNoteId1', + title:'My Title', + body:'My body for note', + userId: 'testUserId1', + updatedAt: 0 + } + + const noteTwo = { + _id:'testNoteId2', + title:'My Title 2', + body:'My body for note 2', + userId: 'testUserId2', + updatedAt: 0 + } + + beforeEach(function(){ + Notes.remove({}); + Notes.insert(noteOne) + Notes.insert(noteTwo) + }) + + it('should insert new note', function(){ + const userId = 'testId' + const _id = Meteor.server.method_handlers['notes.insert'].apply({userId:'testId'}) + + expect(Notes.findOne({_id, userId})).toExist(); + }) + + it('should not insert note if not authentciated',function(){ + expect(()=>{ + Meteor.server.method_handlers['notes.insert'](); + }).toThrow(); + }) + + it('should remove note', function(){ + Meteor.server.method_handlers['notes.remove'].apply({userId:noteOne.userId},[noteOne._id]) + + expect(Notes.findOne({_id: noteOne._id})).toNotExist(); + }) + + it('should not remove note if unauthenticated', function(){ + expect(()=>{ + Meteor.server.method_handlers['notes.remove'].apply({},[noteOne._id]) + }).toThrow(); + }) + + it('should not remove note if invalid _id', function(){ + expect(()=>{ + Meteor.server.method_handlers['notes.remove'].apply({userId:noteOne.userId},[]) + }).toThrow(); + }) + + it('should update note', function(){ + const title = 'This is an updated title' + + Meteor.server.method_handlers['notes.update'].apply({userId:noteOne.userId},[noteOne._id, {title}]) + + const note = Notes.findOne(noteOne._id) + + expect(note.updatedAt).toBeGreaterThan(0) + expect(note).toInclude({title,body:noteOne.body}) + }) + + it('should throw err if extra updates', function(){ + expect(()=>{ + Meteor.server.method_handlers['notes.remove'].apply({userId:noteOne.userId},[noteOne._id, title:'new title', name:'name']) + }).toThrow(); + }) + + it('should not update note if user is not creater', function(){ + const title = 'This is an updated title' + Meteor.server.method_handlers['notes.remove'].apply({userId:'testid'},[noteOne._id, {title}]) + const note = Notes.findOne(noteOne._id) + expect(note).toInclude(noteOne) + }) + + it('should not update note if unauthenticated', function(){ + expect(()=>{ + Meteor.server.method_handlers['notes.update'].apply({},[noteOne._id]) + }).toThrow(); + }) + + it('should not update note if invalid _id', function(){ + expect(()=>{ + Meteor.server.method_handlers['notes.update'].apply({userId:noteOne.userId},[]) + }).toThrow(); + }) + + it('should return a users notes', function(){ + const res = Meteor.server.publish_handlers.notes.apply({userId:'testUserId2'}) + const notes = res.fetch(); + + expect(notes.length).toBe(1) + expect(notes[0]).toEqual(noteTwo) + }) + + it('should return zero notes', function(){ + const res = Meteor.server.publish_handlers.notes.apply({userId:'testUserId3'}) + const notes = res.fetch(); + + expect(notes.length).toBe(0) + }) + + + + }) +} diff --git a/import/api/users.js b/import/api/users.js index 1ba27dc..3ae7774 100644 --- a/import/api/users.js +++ b/import/api/users.js @@ -2,19 +2,22 @@ import { Meteor } from 'meteor/meteor'; import SimpleSchema from 'simpl-schema'; import {Accounts} from 'meteor/accounts-base'; -// Accounts.validateNewUser((user)=>{ -// const email = user.emails[0].address; -// try{ -// new SimpleSchema({ -// email:{ -// type:String, -// regEx: SimpleSchema.RegEx.Email -// } -// }).validate({email}) -// } catch(e){ -// console.log('e',e,e.message); -// throw new Meteor.Error(400, e.message) -// } -// -// return true; -// }) +export const validateNewUser = (user)=>{ + const email = user.emails[0].address; + try{ + new SimpleSchema({ + email:{ + type:String, + regEx: SimpleSchema.RegEx.Email + } + }).validate({email}) + }catch(e){ + console.log('e',e,e.message); + throw new Meteor.Error(400, e.message) + } + + return true; +} +if(Meteor.isServer){ + Accounts.validateNewUser(validateNewUser) +} diff --git a/import/api/users.test.js b/import/api/users.test.js new file mode 100644 index 0000000..2506cf3 --- /dev/null +++ b/import/api/users.test.js @@ -0,0 +1,63 @@ +import {Meteor} from 'meteor/meteor' +import expect from 'expect' + +import {validateNewUser} from './users' + +if(Meteor.isServer){ + describe('users', function(){ + it('should allow valid email address',function(){ + const testUser = { + emails:[ + {address:'Test@example.com'} + ] + } + const res = validateNewUser(testUser) + + expect(res).toBe(true); + }) + + it('should reject invalid email', function(){ + const testUser={ + emails:[ + {address:'testtests'} + ] + } + + expect(()=>{ + validateNewUser(testUser); + }).toThrow(); + }) + }) + +} + +// const add = (a,b) => { +// if (typeof b !== 'number') return a + a; +// else return a+b; +// } +// +// const square = (a)=>{ +// return a*a; +// } +// +// describe('add', function(){ +// it('should add two number', function(){ +// const res = add(3,4) +// // if(res!==7) throw new Error('Sum was not equal to expected value') +// expect(res).toBe(20); +// }); +// +// it('should double a single number' , function(){ +// const res = add(33); +// // if(res!==88) throw new Error('Number was not doubled') +// expect(res).toBe(66); +// }) +// +// }) +// +// describe('square', function(){ +// it('should square a number', function(){ +// const res = square(11); +// expect(res).toBe(121); +// }) +// }) diff --git a/import/ui/dashboard.js b/import/ui/dashboard.js index c23ec99..28d77a3 100644 --- a/import/ui/dashboard.js +++ b/import/ui/dashboard.js @@ -1,13 +1,14 @@ import React from 'react'; import PrivateHeader from './privateHeader'; +import NoteList from './noteList' export default () =>{ return(
- Dasboard page content +
) diff --git a/import/ui/login.js b/import/ui/login.js index 01fc5ba..5f1a23b 100644 --- a/import/ui/login.js +++ b/import/ui/login.js @@ -1,8 +1,10 @@ import {Meteor} from 'meteor/meteor'; import React from 'react'; import {Link} from 'react-router-dom'; +import {createContainer} from 'meteor/react-meteor-data' +import PropTypes from 'prop-types' -export default class Login extends React.Component{ +export class Login extends React.Component{ constructor(props){ super(props); @@ -17,7 +19,7 @@ export default class Login extends React.Component{ let email = this.refs.email.value.trim(); let password = this.refs.password.value.trim(); - Meteor.loginWithPassword({email},password,(err)=>{ + this.props.loginWithPassword({email},password,(err)=>{ err? this.setState({error: err.reason}):this.setState({error: ""}) }) @@ -43,3 +45,13 @@ export default class Login extends React.Component{ ) } } + +Login.propTypes={ + loginWithPassword:PropTypes.func +} + +export default createContainer(()=>{ + return{ + loginWithPassword: Meteor.loginWithPassword() + } +}, Login) diff --git a/import/ui/login.test.js b/import/ui/login.test.js new file mode 100644 index 0000000..c894810 --- /dev/null +++ b/import/ui/login.test.js @@ -0,0 +1,52 @@ +import {Meteor} from 'meteor/meteor' +import React from 'react' +import expect from 'expect' +import ReactTestUtils from 'react-dom/test-utils' +import {mount} from 'enzyme' + +import {Login} from './login' + +if(Meteor.isClient){ + describe('Login', function(){ + + it('should show err message', function(){ + const error = 'err msg' + const wrapper = mount({}}/>) + + wrapper.setState({error}) + const err = wrapper.find('p').text() + expect(err).toBe(error) + + wrapper.setState({error:""}) + const errr = wrapper.find('p').length + expect(errr).toBe(0) + }) + + it('should call loginWithPassword with the form data', function(){ + const email = 'test@test.com' + const password = '123' + const spy = expect.createSpy() + const wrapper = mount() + + wrapper.ref('email').node.value = email; + wrapper.ref('password').node.value = password; + wrapper.find('form').simulate('submit'); + + expect(spy.calls[0].arguments[0]).toEqual({email}) + expect(spy.calls[0].argunments[1]).toBe(password) + }) + + it('should call loginWithPassword callback errors', function(){ + const spy = expect.createSpy(); + const wrapper = mount() + + wrapper.find('form').simulate('submit') + + spy.calls[0].argunments[2]({}) + expect(wrapper).state('error').toNotBe('') + + spy.calls[0].argunments[2]() + expect(wrapper).state('error').toNotBe('') + }) + }) +} diff --git a/import/ui/noteList.js b/import/ui/noteList.js new file mode 100644 index 0000000..c2fda2a --- /dev/null +++ b/import/ui/noteList.js @@ -0,0 +1,28 @@ +import React from 'react' +import {Meteor} from 'meteor/meteor' +import {createContainer} from 'meteor/react-meteor-data' +import PropTypes from 'prop-types' + +import {Notes} from '../api/notes' +import {NoteListHeader} from './noteListHeader' + +export const NoteList = (props) =>{ + return( +
+ + NotesList {props.notes.length} +
+ ) +} + +NoteList.PropTypes={ + notes:PropTypes.func +} + +export default createContainer(()=>{ + Meteor.subscribe('notes'); + + return{ + notes: Notes.find().fetch() + } +},NoteList) diff --git a/import/ui/noteListHeader.js b/import/ui/noteListHeader.js new file mode 100644 index 0000000..0fe53db --- /dev/null +++ b/import/ui/noteListHeader.js @@ -0,0 +1,38 @@ +import React from 'react' +import {Meteor} from 'meteor/meteor' +import {createContainer} from 'meteor/react-meteor-data' +import PropTypes from 'prop-types' + +export class NoteListHeader extends React.Component{ + constructor(props){ + super(props) + this.state={ + + } + } + + onClick(e){ + this.props.meteorCall('notes.insert', (err)=>{ + if(err) throw err; + }) + } + + render(){ + return( +
+ + +
+ ) + } +} + +NoteListHeader.propTypes={ + meteorCall:PropTypes.func +} + +export default createContainer(()=>{ + return{ + meteorCall:Meteor.call + } +}, NoteListHeader) diff --git a/import/ui/privateHeader.js b/import/ui/privateHeader.js index b59209a..22ff71c 100644 --- a/import/ui/privateHeader.js +++ b/import/ui/privateHeader.js @@ -1,16 +1,27 @@ import React from 'react'; import {Accounts} from 'meteor/accounts-base'; +import {createContainer} from 'meteor/react-meteor-data' +import PropTypes from 'prop-types' //stateless functional component -const PrivateHeader = (props)=>{ +export const PrivateHeader = (props)=>{ return (

{props.title}

- +
) } -export default PrivateHeader; +PrivateHeader.proptypes={ + title: PropTypes.string, + handleLogout: PropTypes.func +} + +export default createContainer(()=>{ + return{ + handleLogout: ()=>Accounts.logout() + }; +}, PrivateHeader); diff --git a/import/ui/privateHeader.test.js b/import/ui/privateHeader.test.js new file mode 100644 index 0000000..b9bb899 --- /dev/null +++ b/import/ui/privateHeader.test.js @@ -0,0 +1,34 @@ +import {Meteor} from 'meteor/meteor' +import React from 'react' +import expect from 'expect' +import {mount} from 'enzyme' + +import {PrivateHeader} from './privateHeader' + +if(Meteor.isClient){ + describe('PrivateHeader', function(){ + it('should set button text to logout', function(){ + const wrapper = mount() + const buttonText = wrapper.find('button').text() + + expect(buttonText).toBe('Logout') + }); + + it('should use title prop as h1 text', function(){ + const title = 'Test title here' + const wrapper = mount() + const h1Title = wrapper.find('h1').text() + + expect(h1Title).toBe(title) + }) + + it('should call handleLogout function', function(){ + const spy = expect.createSpy(); + const wrapper = mount() + + wrapper.find('button').simulate('click'); + + expect(spy).toHaveBeenCalled(); + }) + }) +} diff --git a/import/ui/signup.js b/import/ui/signup.js index de61198..fb86380 100644 --- a/import/ui/signup.js +++ b/import/ui/signup.js @@ -1,8 +1,10 @@ import React from 'react'; import {Link} from 'react-router-dom'; import {Accounts} from 'meteor/accounts-base'; +import {createContainer} from 'meteor/react-meteor-data' +import PropTypes from 'prop-types' -export default class Signup extends React.Component{ +export class Signup extends React.Component{ constructor(props){ super(props); @@ -19,7 +21,7 @@ export default class Signup extends React.Component{ if(password.length<8) return this.setState({error:"Password must be at least 8 characters"}) - Accounts.createUser({email, password}, (err)=>{ + this.props.createUser({email, password}, (err)=>{ err? this.setState({error: err.reason}):this.setState({error: ""}) }) @@ -45,3 +47,13 @@ export default class Signup extends React.Component{ ) } } + +Signup.propTypes={ + createUser:PropTypes.func +} + +export default createContainer(()=>{ + return{ + createUser: Accounts.createUser + } +}, Signup) diff --git a/import/ui/signup.test.js b/import/ui/signup.test.js new file mode 100644 index 0000000..6f2c9f6 --- /dev/null +++ b/import/ui/signup.test.js @@ -0,0 +1,51 @@ +import {Meteor} from 'meteor/meteor' +import React from 'react' +import expect from 'expect' +import {mount} from 'enzyme' + +import {Signup} from './signup' + +if(Meteor.isClient){ + describe('Signup', function(){ + + it('should show err message', function(){ + const error = 'err msg' + const wrapper = mount({}}/>) + + wrapper.setState({error}) + const err = wrapper.find('p').text() + expect(err).toBe(error) + + wrapper.setState({error:""}) + const errr = wrapper.find('p').length + expect(errr).toBe(0) + }) + + it('should call createUser with the form data', function(){ + const email = 'test@test.com' + const password = '123' + const spy = expect.createSpy() + const wrapper = mount() + + wrapper.ref('email').node.value = email; + wrapper.ref('password').node.value = password; + wrapper.find('form').simulate('submit'); + + expect(spy.calls[0].arguments[0]).toEqual({email}) + expect(spy.calls[0].argunments[1]).toBe(password) + }) + + it('should call createUser callback errors', function(){ + const spy = expect.createSpy(); + const wrapper = mount() + + wrapper.find('form').simulate('submit') + + spy.calls[0].argunments[2]({}) + expect(wrapper).state('error').toNotBe('') + + spy.calls[0].argunments[2]() + expect(wrapper).state('error').toNotBe('') + }) + }) +} diff --git a/package.json b/package.json index ca433c5..860815d 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { - "name": "short-lnk", + "name": "notes", "private": true, "scripts": { - "start": "meteor run" + "start": "meteor run", + "test": "meteor test --driver-package=practicalmeteor:mocha" }, "dependencies": { "babel-runtime": "^6.20.0", @@ -11,12 +12,19 @@ "meteor-node-stubs": "~0.2.4", "moment": "^2.18.1", "react": "^15.5.4", + "react-addons-pure-render-mixin": "^15.5.2", "react-dom": "^15.5.4", "react-router": "^4.1.1", "react-router-dom": "^4.1.1", "simpl-schema": "^0.3.0" }, - "engines":{ - "node":"4.8.2" + "engines": { + "node": "4.8.2" + }, + "devDependencies": { + "enzyme": "^2.8.2", + "expect": "^1.20.2", + "react-addons-test-utils": "^15.5.1", + "react-test-renderer": "^15.5.4" } } diff --git a/server/main.js b/server/main.js index 7063a46..d704946 100644 --- a/server/main.js +++ b/server/main.js @@ -3,6 +3,7 @@ import {WebApp} from 'meteor/webapp' import '../import/api/users' +import '../import/api/notes' Meteor.startup(() => {