From 84b6ebd2f1ffd2cc31d67dd892bc32fa76751275 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 30 Apr 2019 11:54:26 +0200 Subject: [PATCH 01/32] Lowercase import names & uppercase file name --- backend/src/models/{user.js => User.js} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename backend/src/models/{user.js => User.js} (91%) diff --git a/backend/src/models/user.js b/backend/src/models/User.js similarity index 91% rename from backend/src/models/user.js rename to backend/src/models/User.js index f9ce09e..dae711f 100644 --- a/backend/src/models/user.js +++ b/backend/src/models/User.js @@ -1,8 +1,8 @@ /* eslint-disable func-names */ const mongoose = require('mongoose'); const bcrypt = require('bcrypt'); -const Address = require('./address'); -const BankDetails = require('./bankDetails'); +const address = require('./address'); +const bankDetails = require('./bankDetails'); const { Schema } = mongoose; @@ -20,8 +20,8 @@ const userSchema = new Schema({ trim: true, lowercase: true }, - address: Address, - bankDetails: BankDetails, + address, + bankDetails, expenses: [ { type: Schema.Types.ObjectId, From 16c40e9693ed2492005b02b0b6b40763e1986880 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 30 Apr 2019 12:00:56 +0200 Subject: [PATCH 02/32] Create Company model --- backend/src/db.js | 1 + backend/src/models/Company.js | 28 ++++++++++++++++++++++++++++ backend/src/models/index.js | 5 +++-- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 backend/src/models/Company.js diff --git a/backend/src/db.js b/backend/src/db.js index bda52a2..25b28b8 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -12,6 +12,7 @@ module.exports.connect = async () => { await mongoose.connection.createCollection('users'); await mongoose.connection.createCollection('transactions'); await mongoose.connection.createCollection('categories'); + await mongoose.connection.createCollection('companies'); console.log('Collections created successfully!'); }) .catch(err => { diff --git a/backend/src/models/Company.js b/backend/src/models/Company.js new file mode 100644 index 0000000..451a014 --- /dev/null +++ b/backend/src/models/Company.js @@ -0,0 +1,28 @@ +const mongoose = require('mongoose'); +const bankDetails = require('./bankDetails'); +const address = require('./address'); + +const { Schema } = mongoose; + +const companySchema = new Schema({ + name: { + type: String, + trim: true + }, + email: { + type: String, + trim: true + }, + phone: { + type: String, + trim: true + }, + VAT: { + type: String, + trim: true + }, + bankDetails, + address +}); + +module.exports = mongoose.model('Company', companySchema); diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 83b8087..8a8ac06 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -1,4 +1,5 @@ -const User = require('./user'); +const User = require('./User'); const Transaction = require('./Transaction'); +const Company = require('./Company'); -module.exports = { User, Transaction }; +module.exports = { User, Transaction, Company }; From 7ce84abc9530ba6ef500dfcac3af309d4f58040e Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 30 Apr 2019 12:03:08 +0200 Subject: [PATCH 03/32] Create Category model --- backend/src/models/Category.js | 12 ++++++++++++ backend/src/models/index.js | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 backend/src/models/Category.js diff --git a/backend/src/models/Category.js b/backend/src/models/Category.js new file mode 100644 index 0000000..12b0c2e --- /dev/null +++ b/backend/src/models/Category.js @@ -0,0 +1,12 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const categorySchema = new Schema({ + name: { + type: String, + trim: true + } +}); + +module.exports = mongoose.model('Category', categorySchema); diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 8a8ac06..80740c2 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -1,5 +1,6 @@ const User = require('./User'); const Transaction = require('./Transaction'); const Company = require('./Company'); +const Category = require('./Category'); -module.exports = { User, Transaction, Company }; +module.exports = { User, Transaction, Company, Category }; From af5dc88ae99595a381a7d7d07f63ebc4cd09c171 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 30 Apr 2019 12:19:29 +0200 Subject: [PATCH 04/32] Create graphql types Category & Company --- backend/src/graphql/types.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js index a7a31e1..6b5607b 100644 --- a/backend/src/graphql/types.js +++ b/backend/src/graphql/types.js @@ -57,6 +57,20 @@ module.exports = gql` type: TransactionType! } + type Category { + id: ID! + name: String! + } + + type Company { + name: String! + email: String + phone: String + VAT: String + bankDetails: BankDetails + address: Address + } + #inputs input AdressInput { street: String! From 37b322204e420e3b08b96cf07b9bbe0acbac06c9 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 30 Apr 2019 12:33:58 +0200 Subject: [PATCH 05/32] Add category & company to Transaction --- backend/src/graphql/types.js | 3 ++- backend/src/models/Transaction.js | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js index 6b5607b..f9bb10f 100644 --- a/backend/src/graphql/types.js +++ b/backend/src/graphql/types.js @@ -45,6 +45,8 @@ module.exports = gql` type Transaction { id: ID! + category: Category + company: Company flow: Flow! state: State! user: User! @@ -103,7 +105,6 @@ module.exports = gql` input Expense { amount: Float! date: String - expDate: String description: String! receipt: Upload! VAT: Int diff --git a/backend/src/models/Transaction.js b/backend/src/models/Transaction.js index 03dcae9..967471f 100644 --- a/backend/src/models/Transaction.js +++ b/backend/src/models/Transaction.js @@ -11,10 +11,12 @@ const transactionSchema = new Schema({ type: String // should be ObjectId }, company: { - type: String // should be ObjectId + type: Schema.Types.ObjectId, + ref: 'Company' }, category: { - type: String // should be ObjectId + type: Schema.Types.ObjectId, + ref: 'Category' }, user: { type: Schema.Types.ObjectId, From f33bc2a84644c0b879724cf74cb7b6c07aeb8c5c Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 30 Apr 2019 14:06:54 +0200 Subject: [PATCH 06/32] Add InvoiceUpload & CompanyInput --- backend/src/graphql/types.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js index f9bb10f..95b2c74 100644 --- a/backend/src/graphql/types.js +++ b/backend/src/graphql/types.js @@ -110,6 +110,25 @@ module.exports = gql` VAT: Int } + input InvoiceUpload { + amount: Float! + date: String + category: String + company: CompanyInput + expDate: String + invoice: Upload! + VAT: Int + } + + input CompanyInput { + name: String! + email: String + phone: String + VAT: String + bankDetails: BankDetailsInput + address: AdressInput + } + #enums enum Flow { IN From b10b1cd68d4dc1e3a769cbaf746b3d90ed7a632a Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Thu, 2 May 2019 12:22:03 +0200 Subject: [PATCH 07/32] Add invoice upload validation & make the validation components more reusable --- backend/src/lib/validation.js | 108 +++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 21 deletions(-) diff --git a/backend/src/lib/validation.js b/backend/src/lib/validation.js index de59635..9ba0e12 100644 --- a/backend/src/lib/validation.js +++ b/backend/src/lib/validation.js @@ -41,54 +41,102 @@ const minMessage = (field, validation, args) => { const maxMessage = (field, validation, args) => TOO_LONG(field, args[0]); const aboveMessage = (field, validation, args) => MUST_BE_ABOVE(field, args[0]); +const addressValidation = { + rules: { + 'address.street': `required_with_any:address.city,address.country,address.zipCode|max:${MAX_LENGTH}`, + 'address.city': `required_with_any:address.street,address.country,address.zipCode|max:${MAX_LENGTH}`, + 'address.country': `required_with_any:address.street,address.city,address.zipCode|max:${MAX_LENGTH}` + }, + messages: { required_with_any: requiredMessage, max: maxMessage } +}; + +const bankDetailsValidation = { + rules: { + 'bankDetails.bic': `requiredIf:bankDetails.iban|max:${MAX_LENGTH}`, + 'bankDetails.iban': `requiredIf:bankDetails.bic|max:${MAX_LENGTH}` // TODO change to IBAN format + }, + messages: { requiredIf: requiredMessage, max: maxMessage } +}; + +const companyValidation = { + rules: { + 'company.name': `required|max:${MAX_LENGTH}`, + 'company.email': 'email', + 'company.phone': `max:${MAX_LENGTH}`, // TODO change to regex + 'company.VAT': `max:12`, // TODO change to regex ? + ...addressValidation.rules, + ...bankDetailsValidation.rules + }, + messages: { + required: requiredMessage, + max: maxMessage, + email: WRONG_EMAIL_FORMAT, + ...bankDetailsValidation.messages, + ...addressValidation.messages + }, + formatData: data => ({ + ...data, + email: data.email === null ? undefined : data.email, + address: { ...data.address }, + bankDetails: { ...data.bankDetails } + }) +}; + +// module.exports = companyValidation; + +// const categoryValidation = { +// rules: { +// name: `required|max:${MAX_LENGTH}` +// }, +// messages: { +// required: requiredMessage +// } +// }; + +// module.exports = categoryValidation; + exports.registerValidation = { rules: { email: 'email|required', name: `required|max:${MAX_LENGTH}`, password: `required|min:8|max:1000`, - street: `required_with_any:city,country,zipCode|max:${MAX_LENGTH}`, - city: `required_with_any:street,country,zipCode|max:${MAX_LENGTH}`, - country: `required_with_any:street,city,zipCode|max:${MAX_LENGTH}`, - bic: `requiredIf:iban|max:${MAX_LENGTH}`, - iban: `requiredIf:bic|max:${MAX_LENGTH}` // TODO change to IBAN format + ...addressValidation.rules, + ...bankDetailsValidation.rules }, messages: { required: requiredMessage, - requiredIf: requiredMessage, - required_with_any: requiredMessage, + ...addressValidation.messages, + ...bankDetailsValidation.messages, min: minMessage, max: maxMessage, - 'email.email': WRONG_EMAIL_FORMAT + email: WRONG_EMAIL_FORMAT }, formatData: data => { return { email: data.email, name: data.name, password: data.password, - ...data.address, - ...data.bankDetails + bankDetails: { ...data.bankDetails }, + address: { ...data.address } }; } }; exports.updateProfileValidation = { rules: { - email: 'min:1|email', + email: 'email', name: `min:1|max:${MAX_LENGTH}`, password: `min:8|max:1000`, - street: `required_with_any:city,country,zipCode|max:${MAX_LENGTH}`, - city: `required_with_any:street,country,zipCode|max:${MAX_LENGTH}`, - country: `required_with_any:street,city,zipCode|max:${MAX_LENGTH}`, - bic: `requiredIf:iban|max:${MAX_LENGTH}`, - iban: `requiredIf:bic|max:${MAX_LENGTH}` // TODO change to IBAN format + ...addressValidation.rules, + ...bankDetailsValidation.rules }, messages: { required: requiredMessage, - required_with_any: requiredMessage, - requiredIf: requiredMessage, min: minMessage, max: maxMessage, - 'email.email': WRONG_EMAIL_FORMAT + email: WRONG_EMAIL_FORMAT, + ...addressValidation.messages, + ...bankDetailsValidation.messages }, formatData: data => { const formattedData = {}; @@ -99,8 +147,7 @@ exports.updateProfileValidation = { else formattedData.name = ''; if (data.password !== null) formattedData.password = data.password; else formattedData.password = ''; - - return { ...formattedData, ...data.bankDetails, ...data.address }; + return { ...formattedData, bankDetails: { ...data.bankDetails }, address: { ...data.address } }; } }; @@ -121,3 +168,22 @@ exports.expenseValidation = { return { VAT: data.VAT, amount: data.amount, date: data.date, description: data.description }; } }; + +exports.uploadInvoiceValidation = { + rules: { + VAT: 'above:-1', + amount: 'above:-1', + date: 'date', + expDate: 'date', + ...companyValidation.rules, + 'company.name': `min:1|max:${MAX_LENGTH}` + }, + messages: { + above: aboveMessage, + date: INVALID_DATE_FORMAT, + min: minMessage, + max: maxMessage, + ...companyValidation.messages + }, + formatData: data => ({ ...data, company: companyValidation.formatData({ ...data.company }) }) +}; From fcd5d6b85ee5d2a465ab9456de7140c6ca552bdd Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Thu, 2 May 2019 12:23:34 +0200 Subject: [PATCH 08/32] Implement upload invoice resolver --- backend/src/graphql/resolvers/mutations.js | 50 +++++++++++++++++++++- backend/src/graphql/types.js | 4 +- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/backend/src/graphql/resolvers/mutations.js b/backend/src/graphql/resolvers/mutations.js index 79da88c..a1caea1 100644 --- a/backend/src/graphql/resolvers/mutations.js +++ b/backend/src/graphql/resolvers/mutations.js @@ -2,9 +2,25 @@ const { validate, registerValidation, updateProfileValidation, - expenseValidation + expenseValidation, + uploadInvoiceValidation } = require('../../lib/validation'); +const saveOrRetrieveCompany = async (company, Company) => { + if (!company) return null; + const { name, id } = company; + if (!name && !id) return null; + if (company.id) { + return Company.findById(id); + } + if (company) { + const cmpny = await Company.findOne({ name }); + if (cmpny) return cmpny; + return new Company(company).save(); + } + return null; +}; + const store = (file, tags, folder, cloudinary) => new Promise((resolve, reject) => { const uploadStream = cloudinary.uploader.upload_stream({ tags, folder }, (err, image) => { @@ -87,12 +103,42 @@ module.exports = { } } return User.findOneAndUpdate({ _id: user.id }, args.user, { new: true }); + }, + uploadInvoice: async ( + root, + { invoice }, + { models: { Transaction, Company, Category }, cloudinary } + ) => { + const { formatData, rules, messages } = uploadInvoiceValidation; + await validate(formatData({ ...invoice }), rules, messages); + const company = await saveOrRetrieveCompany(invoice.company, Company); + invoice.company = company; + if (invoice.category && (invoice.category.name || invoice.category.id)) { + const category = Category.findOne({ + $or: [{ _id: company.category.id }, { name: company.category.id }] + }); + if (!category) { + throw new Error('Category not found.'); + } + invoice.category = category; + } + invoice.flow = 'IN'; + invoice.type = 'INVOICE'; + invoice.date = invoice.date || Date.now(); + invoice.invoice = await invoice.invoice; + + const file = await store(invoice.invoice, 'invoice', '/invoices/pending', cloudinary); + invoice.file = file.secure_url; + + return new Transaction(invoice).save(); + + // console.log(invoice); } }; // TODO REMOVE EXAMPLE // { -// "query": "mutation ($amount: Float!, $description: String!, $receipt: Upload!) {expenseClaim(expense: {amount: $amount, description: $description, receipt: $receipt}) {id}}", +// "query": "mutation ($amount: Float!, $invoice: Upload!) {expenseClaim(expense: {amount: $amount, invoice: $invoice}) {id flow}}", // "variables": { // "amount": 10, // "description": "Hello World!", diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js index 95b2c74..dedab97 100644 --- a/backend/src/graphql/types.js +++ b/backend/src/graphql/types.js @@ -18,6 +18,7 @@ module.exports = gql` login(email: String!, password: String!): User! @guest expenseClaim(expense: Expense!): Transaction! @auth updateProfile(user: UserUpdateInput!): User! @auth + uploadInvoice(invoice: InvoiceUpload!): Transaction! } type Success { status: Boolean! @@ -121,7 +122,8 @@ module.exports = gql` } input CompanyInput { - name: String! + id: ID + name: String email: String phone: String VAT: String From 069411909917c8b9dedcb5c9d499c34ee28781ae Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Thu, 2 May 2019 14:00:16 +0200 Subject: [PATCH 09/32] Test register input validation --- backend/src/tests/validations.test.js | 124 ++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 backend/src/tests/validations.test.js diff --git a/backend/src/tests/validations.test.js b/backend/src/tests/validations.test.js new file mode 100644 index 0000000..7288768 --- /dev/null +++ b/backend/src/tests/validations.test.js @@ -0,0 +1,124 @@ +const { registerValidation, validate } = require('../lib/validation'); + +describe('EXPENSE CLAIM VALIDATION', () => {}); +describe('REGISTER VALIDATION', () => { + const { rules, messages } = registerValidation; + const user = { + email: 'test@mail.com', + name: 'Test Test', + password: 'azerty1234', + address: { + street: 'My Street 31', + city: 'My City', + country: 'My Country', + zipCode: 1000 + }, + bankDetails: { + iban: 'MY IBAN', + bic: 'MY BIC' + } + }; + it("doesn't throw (minimal config)", async () => { + const data = { + email: user.email, + name: user.name, + password: user.password + }; + + await validate(data, rules, messages); + }); + + it("doesn't throw (with address)", async () => { + const data = { + ...user, + bankDetails: {} + }; + await validate(data, rules, messages); + }); + + it("doesn't throw (with bankDetails)", async () => { + const data = { + ...user, + address: {} + }; + await validate(data, rules, messages); + }); + + it("doesn't throw (full config)", async () => { + const data = user; + await validate(data, rules, messages); + }); + + it('throws on missing required field', async () => { + const data = {}; + try { + await validate(data, rules, messages); + expect(false).toBe(true); + } catch (error) { + const err = JSON.parse(error.message); + expect(err[0].email).toBeTruthy(); + expect(err[1].email).toBeTruthy(); + expect(err[2].name).toBeTruthy(); + expect(err[3].password).toBeTruthy(); + } + }); + + it('throws if one of bankDetails field is missing while the other is present', async () => { + const data = { + ...user, + bankDetails: { + iban: 'MY IBAN' + } + }; + try { + await validate(data, rules, messages); + } catch (error) { + const err = JSON.parse(error.message); + expect(err[0]['bankDetails.bic']).toBeTruthy(); + } + try { + await validate({ ...data, bankDetails: { bic: 'MY BIC' } }, rules, messages); + expect(false).toBe(true); + } catch (error) { + const err = JSON.parse(error.message); + expect(err[0]['bankDetails.iban']).toBeTruthy(); + } + }); + + it('throws if any of address fields are missing while one of them is present', async () => { + // street is present + try { + await validate({ ...user, address: { street: 'My street' } }, rules, messages); + expect(false).toBe(true); + } catch (error) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } + // city is present + try { + await validate({ ...user, address: { city: 'My city' } }, rules, messages); + expect(false).toBe(true); + } catch (error) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } + // zipCode is present + try { + await validate({ ...user, address: { zipCode: 1000 } }, rules, messages); + expect(false).toBe(true); + } catch (error) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(3); + } + // country is present + try { + await validate({ ...user, address: { country: 'My country' } }, rules, messages); + expect(false).toBe(true); + } catch (error) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } + }); +}); +describe('UPDATE PROFILE VALIDATION', () => {}); +describe('UPLOAD INVOICE VALIDATION', () => {}); From 0d7d107f6024322dd8da1bdf4f9f0e3070f8fe0d Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Thu, 2 May 2019 14:39:28 +0200 Subject: [PATCH 10/32] Better error handling & password test --- backend/src/tests/validations.test.js | 61 +++++++++++++++++++-------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/backend/src/tests/validations.test.js b/backend/src/tests/validations.test.js index 7288768..581216a 100644 --- a/backend/src/tests/validations.test.js +++ b/backend/src/tests/validations.test.js @@ -1,3 +1,4 @@ +const { UserInputError } = require('apollo-server-express'); const { registerValidation, validate } = require('../lib/validation'); describe('EXPENSE CLAIM VALIDATION', () => {}); @@ -55,14 +56,28 @@ describe('REGISTER VALIDATION', () => { await validate(data, rules, messages); expect(false).toBe(true); } catch (error) { - const err = JSON.parse(error.message); - expect(err[0].email).toBeTruthy(); - expect(err[1].email).toBeTruthy(); - expect(err[2].name).toBeTruthy(); - expect(err[3].password).toBeTruthy(); + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].email).toBeTruthy(); + expect(err[1].email).toBeTruthy(); + expect(err[2].name).toBeTruthy(); + expect(err[3].password).toBeTruthy(); + } else throw error; } }); + describe('throws if password is not matching requirements', () => { + it('throws if password less than minimal characters', async () => { + try { + await validate({ ...user, password: '1234567' }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) expect(true).toBe(true); + else throw error; + } + }); + }); + it('throws if one of bankDetails field is missing while the other is present', async () => { const data = { ...user, @@ -73,15 +88,19 @@ describe('REGISTER VALIDATION', () => { try { await validate(data, rules, messages); } catch (error) { - const err = JSON.parse(error.message); - expect(err[0]['bankDetails.bic']).toBeTruthy(); + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0]['bankDetails.bic']).toBeTruthy(); + } else throw error; } try { await validate({ ...data, bankDetails: { bic: 'MY BIC' } }, rules, messages); expect(false).toBe(true); } catch (error) { - const err = JSON.parse(error.message); - expect(err[0]['bankDetails.iban']).toBeTruthy(); + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0]['bankDetails.iban']).toBeTruthy(); + } else throw error; } }); @@ -91,32 +110,40 @@ describe('REGISTER VALIDATION', () => { await validate({ ...user, address: { street: 'My street' } }, rules, messages); expect(false).toBe(true); } catch (error) { - const err = JSON.parse(error.message); - expect(err).toHaveLength(2); + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } else throw error; } // city is present try { await validate({ ...user, address: { city: 'My city' } }, rules, messages); expect(false).toBe(true); } catch (error) { - const err = JSON.parse(error.message); - expect(err).toHaveLength(2); + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } else throw error; } // zipCode is present try { await validate({ ...user, address: { zipCode: 1000 } }, rules, messages); expect(false).toBe(true); } catch (error) { - const err = JSON.parse(error.message); - expect(err).toHaveLength(3); + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(3); + } else throw error; } // country is present try { await validate({ ...user, address: { country: 'My country' } }, rules, messages); expect(false).toBe(true); } catch (error) { - const err = JSON.parse(error.message); - expect(err).toHaveLength(2); + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } else throw error; } }); }); From 5f882cf908f4026c7c39887ea02195030585492d Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Thu, 2 May 2019 14:45:07 +0200 Subject: [PATCH 11/32] Add env variable to set maximum characters allowed for strings --- backend/src/lib/validation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/lib/validation.js b/backend/src/lib/validation.js index 9ba0e12..71f3dd9 100644 --- a/backend/src/lib/validation.js +++ b/backend/src/lib/validation.js @@ -14,7 +14,7 @@ configure({ EXISTY_STRICT: true }); -const MAX_LENGTH = 500; +const MAX_LENGTH = process.env.STRING_MAX_CHAR || 500; exports.validate = (data, rules, messages) => { return new Promise((resolve, reject) => { From 76f509c29d40051e7504208d90984b51943b235c Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Thu, 2 May 2019 15:18:59 +0200 Subject: [PATCH 12/32] Inject validation module --- backend/src/index.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index 705b532..ec8e876 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -15,6 +15,8 @@ const models = require('./models'); const db = require('./db'); const auth = require('./auth'); +const validation = require('./lib/validation'); + const PORT = process.env.PORT || 4000; const app = express(); @@ -29,7 +31,7 @@ app.use( const context = async ({ req, res }) => { const user = await auth.loggedUser(req.cookies, models); // adopting injection pattern to ease mocking - return { req, res, user, auth, models, cloudinary, db }; + return { req, res, user, auth, models, cloudinary, db, validation }; }; const server = new ApolloServer({ @@ -69,5 +71,6 @@ module.exports = { auth, server, app, - cloudinary + cloudinary, + validation }; From 78ba2fe715f796ce7650e63e6b0b8336b2217454 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Thu, 2 May 2019 15:20:25 +0200 Subject: [PATCH 13/32] Test max length for strings --- backend/src/tests/validations.test.js | 31 ++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/backend/src/tests/validations.test.js b/backend/src/tests/validations.test.js index 581216a..f49791f 100644 --- a/backend/src/tests/validations.test.js +++ b/backend/src/tests/validations.test.js @@ -1,5 +1,9 @@ const { UserInputError } = require('apollo-server-express'); -const { registerValidation, validate } = require('../lib/validation'); +const { + validation: { registerValidation, validate } +} = require('../'); + +const FIFTYONECHARSSTR = 'QYE5TOXWrDbi0bSQDbM1KmKOljjR5SihgUJO7aDwkkjUJVJOzk6'; describe('EXPENSE CLAIM VALIDATION', () => {}); describe('REGISTER VALIDATION', () => { @@ -146,6 +150,31 @@ describe('REGISTER VALIDATION', () => { } else throw error; } }); + + it('throws when length over max', async () => { + const data = { + ...user, + name: FIFTYONECHARSSTR, + address: { + city: FIFTYONECHARSSTR, + country: FIFTYONECHARSSTR, + street: FIFTYONECHARSSTR + }, + bankDetails: { + bic: FIFTYONECHARSSTR, + iban: FIFTYONECHARSSTR + } + }; + try { + await validate(data, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(6); + } else throw error; + } + }); }); describe('UPDATE PROFILE VALIDATION', () => {}); describe('UPLOAD INVOICE VALIDATION', () => {}); From 55f6aee9c45f53f4ec371ea071f0bd9b61eed5a3 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Thu, 2 May 2019 16:23:09 +0200 Subject: [PATCH 14/32] Test updateProfileValidtion & expenseValidation --- backend/src/tests/validations.test.js | 129 +++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 3 deletions(-) diff --git a/backend/src/tests/validations.test.js b/backend/src/tests/validations.test.js index f49791f..d4ac1ec 100644 --- a/backend/src/tests/validations.test.js +++ b/backend/src/tests/validations.test.js @@ -1,11 +1,104 @@ const { UserInputError } = require('apollo-server-express'); const { - validation: { registerValidation, validate } + validation: { registerValidation, updateProfileValidation, expenseValidation, validate } } = require('../'); const FIFTYONECHARSSTR = 'QYE5TOXWrDbi0bSQDbM1KmKOljjR5SihgUJO7aDwkkjUJVJOzk6'; -describe('EXPENSE CLAIM VALIDATION', () => {}); +/** + * Test of input validation module + * + * Notes: + * 1. GraphQL will detect undefined values and reject them + * 2. GraphQL won't allow null for Numbers (Int/Float) + * 3. GraphQL allows null value for strings + * 4. GraphQL will make sure required fields have values (but allows empty string) + */ + +describe('EXPENSE CLAIM VALIDATION', () => { + const { messages, rules } = expenseValidation; + const expense = { + VAT: 21, + amount: 10, + date: Date.now(), + description: 'Hello World!' + }; + + it('will pass validation (minimum required fields)', async () => { + await validate({ description: expense.description, amount: expense.amount }, rules, messages); + }); + + it('will pass validation (all required fields)', async () => { + await validate(expense, rules, messages); + }); + + it('fails if description not set', async () => { + try { + // {} is equivalent to description = null + await validate({}, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].description).toBeTruthy(); + } else throw error; + } + try { + await validate({ description: undefined }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].description).toBeTruthy(); + } else throw error; + } + try { + await validate({ description: '' }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].description).toBeTruthy(); + } else throw error; + } + }); + + it('fails on max length exceeded', async () => { + try { + await validate({ description: FIFTYONECHARSSTR }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].description).toBeTruthy(); + } else throw error; + } + }); + + it('fails on max length exceeded', async () => { + try { + await validate({ description: FIFTYONECHARSSTR }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].description).toBeTruthy(); + } else throw error; + } + }); + + it('fails on invalid date', async () => { + try { + await validate({ description: expense.description, date: 'fefle' }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].date).toBeTruthy(); + } else throw error; + } + }); +}); describe('REGISTER VALIDATION', () => { const { rules, messages } = registerValidation; const user = { @@ -176,5 +269,35 @@ describe('REGISTER VALIDATION', () => { } }); }); -describe('UPDATE PROFILE VALIDATION', () => {}); +describe('UPDATE PROFILE VALIDATION', () => { + // most cases have been tested in REGISTER VALIDATION + const { formatData, messages, rules } = updateProfileValidation; + it('does not require any field', async () => { + await validate(formatData({}), rules, messages); + }); + + it('throws on empty string', async () => { + try { + await validate(formatData({ email: '', name: '', password: '' }), rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(3); + } else throw error; + } + }); + + it('throws on null value', async () => { + try { + await validate(formatData({ email: null, name: null, password: null }), rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(3); + } else throw error; + } + }); +}); describe('UPLOAD INVOICE VALIDATION', () => {}); From 5bc435e07263b5356d4b204dc2ee6b0e1bddeffd Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Thu, 2 May 2019 17:22:31 +0200 Subject: [PATCH 15/32] Test validation of upload invoice inputs --- backend/src/tests/validations.test.js | 115 ++++++++++++++++++++++---- 1 file changed, 101 insertions(+), 14 deletions(-) diff --git a/backend/src/tests/validations.test.js b/backend/src/tests/validations.test.js index d4ac1ec..9454dbf 100644 --- a/backend/src/tests/validations.test.js +++ b/backend/src/tests/validations.test.js @@ -1,10 +1,14 @@ const { UserInputError } = require('apollo-server-express'); const { - validation: { registerValidation, updateProfileValidation, expenseValidation, validate } + validation: { + registerValidation, + updateProfileValidation, + expenseValidation, + uploadInvoiceValidation, + validate + } } = require('../'); -const FIFTYONECHARSSTR = 'QYE5TOXWrDbi0bSQDbM1KmKOljjR5SihgUJO7aDwkkjUJVJOzk6'; - /** * Test of input validation module * @@ -14,6 +18,17 @@ const FIFTYONECHARSSTR = 'QYE5TOXWrDbi0bSQDbM1KmKOljjR5SihgUJO7aDwkkjUJVJOzk6'; * 3. GraphQL allows null value for strings * 4. GraphQL will make sure required fields have values (but allows empty string) */ +const address = { + street: 'My Street 31', + city: 'My City', + country: 'My Country', + zipCode: 1000 +}; +const bankDetails = { + iban: 'MY IBAN', + bic: 'MY BIC' +}; +const FIFTYONECHARSSTR = 'QYE5TOXWrDbi0bSQDbM1KmKOljjR5SihgUJO7aDwkkjUJVJOzk6'; describe('EXPENSE CLAIM VALIDATION', () => { const { messages, rules } = expenseValidation; @@ -105,16 +120,8 @@ describe('REGISTER VALIDATION', () => { email: 'test@mail.com', name: 'Test Test', password: 'azerty1234', - address: { - street: 'My Street 31', - city: 'My City', - country: 'My Country', - zipCode: 1000 - }, - bankDetails: { - iban: 'MY IBAN', - bic: 'MY BIC' - } + address, + bankDetails }; it("doesn't throw (minimal config)", async () => { const data = { @@ -300,4 +307,84 @@ describe('UPDATE PROFILE VALIDATION', () => { } }); }); -describe('UPLOAD INVOICE VALIDATION', () => {}); +describe('UPLOAD INVOICE VALIDATION', () => { + const { formatData, messages, rules } = uploadInvoiceValidation; + const invoice = { + company: { + address, + bankDetails, + name: 'OKBE', + VAT: 'BE0000000000', + phone: '0483473742' + }, + phone: '0483473741', + VAT: 21, + amount: 10, + date: Date.now(), + expDate: Date.now() + }; + + it('passes validation with minial required fields', async () => { + await validate(formatData({}), rules, messages); + }); + + it('passes validation with full fields', async () => { + await validate(formatData(invoice), rules, messages); + }); + + it('fails on invalid date', async () => { + try { + await validate(formatData({ date: 'eeee', expDate: 'eeee' }), rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } else throw error; + } + }); + + it('fails on negative value', async () => { + try { + await validate(formatData({ amount: -1, VAT: -1 }), rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } else throw error; + } + }); + + // TODO make this describe a root + describe('COMPANY VALIDATION', () => { + // Address & BankDetails are tested in REGISTER VALIDATION + it('passes with minimal required fields', async () => { + await validate(formatData({ company: { name: 'OKBE' } }), rules, messages); + }); + it('passes with full fields', async () => { + await validate(formatData({ company: invoice.company }), rules, messages); + }); + it('fails on max length exceeded', async () => { + try { + await validate( + formatData({ + company: { + name: FIFTYONECHARSSTR, + VAT: `${invoice.company.VAT}1`, + phone: FIFTYONECHARSSTR + } + }), + rules, + messages + ); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(3); + } else throw error; + } + }); + }); +}); From 720102e7adb7f5101f1b072f6070e2c481dff27b Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 3 May 2019 09:42:25 +0200 Subject: [PATCH 16/32] Add more tests for register endpoint --- .../src/tests/__snapshots__/e2e.test.js.snap | 23 +++++++++++++++++++ backend/src/tests/e2e.test.js | 21 +++++++++++++++++ backend/src/tests/graphql/queryStrings.js | 10 ++++++++ 3 files changed, 54 insertions(+) diff --git a/backend/src/tests/__snapshots__/e2e.test.js.snap b/backend/src/tests/__snapshots__/e2e.test.js.snap index ac4abd9..8c394ed 100644 --- a/backend/src/tests/__snapshots__/e2e.test.js.snap +++ b/backend/src/tests/__snapshots__/e2e.test.js.snap @@ -155,9 +155,32 @@ exports[`Server - e2e Register succeeds & returns lower case email 1`] = ` Object { "data": Object { "register": Object { + "address": null, + "bankDetails": null, "email": "test@email.com", "name": "Test Test", }, }, } `; + +exports[`Server - e2e Register succeeds & saves address and bankDetails 1`] = ` +Object { + "data": Object { + "register": Object { + "address": Object { + "city": "My city", + "country": "My country", + "street": "My street", + "zipCode": 1000, + }, + "bankDetails": Object { + "bic": "MY BIC", + "iban": "MY IBAN", + }, + "email": "test1@gmail.com", + "name": "Test Test", + }, + }, +} +`; diff --git a/backend/src/tests/e2e.test.js b/backend/src/tests/e2e.test.js index 5472a28..c1fdcd5 100644 --- a/backend/src/tests/e2e.test.js +++ b/backend/src/tests/e2e.test.js @@ -5,6 +5,8 @@ const { startTestServer, toPromise, populate, clean } = require('./utils'); const { GET_ME, LOGIN_ME_IN, REGISTER, ALL_USERS } = require('./graphql/queryStrings'); const testUser = { user: { name: 'Test Test', email: 'tesT@email.com', password: 'testing0189' } }; +const bankDetails = { iban: 'MY IBAN', bic: 'MY BIC' }; +const address = { street: 'My street', city: 'My city', zipCode: 1000, country: 'My country' }; describe('Server - e2e', () => { let stop; @@ -120,5 +122,24 @@ describe('Server - e2e', () => { expect(res).toMatchSnapshot(); expect(res.data.register.email).toBe('test@email.com'); }); + + it('succeeds & saves address and bankDetails', async () => { + const res = await toPromise( + graphql({ + query: REGISTER, + variables: { + user: { + ...testUser.user, + bankDetails, + address, + email: 'test1@gmail.com' + } + } + }) + ); + expect(res).toMatchSnapshot(); + expect(res.data.register.bankDetails).toBeTruthy(); + expect(res.data.register.address).toBeTruthy(); + }); }); }); diff --git a/backend/src/tests/graphql/queryStrings.js b/backend/src/tests/graphql/queryStrings.js index 6c5336d..b40fa4a 100644 --- a/backend/src/tests/graphql/queryStrings.js +++ b/backend/src/tests/graphql/queryStrings.js @@ -24,6 +24,16 @@ module.exports.REGISTER = gql` register(user: $user) { name email + bankDetails { + iban + bic + } + address { + street + city + zipCode + country + } } } `; From e7ea283081d3108b1a7fe5a5bb83809ed18e3649 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 3 May 2019 10:36:02 +0200 Subject: [PATCH 17/32] Test update profile --- .../src/tests/__snapshots__/e2e.test.js.snap | 23 +++++ backend/src/tests/e2e.test.js | 20 +++- backend/src/tests/graphql/queryStrings.js | 19 ++++ backend/src/tests/integration.test.js | 99 ++++++++++++++++++- 4 files changed, 157 insertions(+), 4 deletions(-) diff --git a/backend/src/tests/__snapshots__/e2e.test.js.snap b/backend/src/tests/__snapshots__/e2e.test.js.snap index 8c394ed..20d2231 100644 --- a/backend/src/tests/__snapshots__/e2e.test.js.snap +++ b/backend/src/tests/__snapshots__/e2e.test.js.snap @@ -184,3 +184,26 @@ Object { }, } `; + +exports[`Server - e2e Update profile requires authenticated user 1`] = ` +Object { + "data": null, + "errors": Array [ + Object { + "extensions": Object { + "code": "UNAUTHENTICATED", + }, + "locations": Array [ + Object { + "column": 3, + "line": 2, + }, + ], + "message": "You must be logged in.", + "path": Array [ + "updateProfile", + ], + }, + ], +} +`; diff --git a/backend/src/tests/e2e.test.js b/backend/src/tests/e2e.test.js index c1fdcd5..0a490a6 100644 --- a/backend/src/tests/e2e.test.js +++ b/backend/src/tests/e2e.test.js @@ -2,7 +2,13 @@ const { server, db } = require('../'); const { startTestServer, toPromise, populate, clean } = require('./utils'); -const { GET_ME, LOGIN_ME_IN, REGISTER, ALL_USERS } = require('./graphql/queryStrings'); +const { + GET_ME, + LOGIN_ME_IN, + REGISTER, + ALL_USERS, + UPDATE_PROFILE +} = require('./graphql/queryStrings'); const testUser = { user: { name: 'Test Test', email: 'tesT@email.com', password: 'testing0189' } }; const bankDetails = { iban: 'MY IBAN', bic: 'MY BIC' }; @@ -142,4 +148,16 @@ describe('Server - e2e', () => { expect(res.data.register.address).toBeTruthy(); }); }); + + describe('Update profile', () => { + it('requires authenticated user', async () => { + const res = await toPromise( + graphql({ + query: UPDATE_PROFILE, + variables: { user: {} } + }) + ); + expect(res).toMatchSnapshot(); + }); + }); }); diff --git a/backend/src/tests/graphql/queryStrings.js b/backend/src/tests/graphql/queryStrings.js index b40fa4a..ab057bf 100644 --- a/backend/src/tests/graphql/queryStrings.js +++ b/backend/src/tests/graphql/queryStrings.js @@ -38,6 +38,25 @@ module.exports.REGISTER = gql` } `; +module.exports.UPDATE_PROFILE = gql` + mutation updateProfile($user: UserUpdateInput!) { + updateProfile(user: $user) { + name + email + bankDetails { + iban + bic + } + address { + street + city + zipCode + country + } + } + } +`; + module.exports.ALL_USERS = gql` query users { users { diff --git a/backend/src/tests/integration.test.js b/backend/src/tests/integration.test.js index 0e5da75..879bc3a 100644 --- a/backend/src/tests/integration.test.js +++ b/backend/src/tests/integration.test.js @@ -3,7 +3,7 @@ const FormData = require('form-data'); const fetch = require('node-fetch'); const fs = require('fs'); const { auth } = require('./mocks/index'); -const { db, models, cloudinary } = require('../'); +const { db, models, cloudinary, validation } = require('../'); const { constructTestServer, startTestServer, populate, clean } = require('./utils'); const { @@ -11,10 +11,13 @@ const { GET_ME, REGISTER, ALL_USERS, - EXPENSE_CLAIM_WITHOUT_GQL + EXPENSE_CLAIM_WITHOUT_GQL, + UPDATE_PROFILE } = require('./graphql/queryStrings'); const testUser = { user: { name: 'Test Test', email: 'test@email.com', password: 'testing0189' } }; +const bankDetails = { iban: 'MY IBAN', bic: 'MY BIC' }; +const address = { street: 'My street', city: 'My city', zipCode: 1000, country: 'My country' }; describe('Authenticated user', () => { let loggedUser; @@ -90,6 +93,96 @@ describe('Authenticated user', () => { expect(res.data.users.length).toBeGreaterThanOrEqual(3); }); + describe('update profile', () => { + let server; + let user; + beforeAll(async () => { + // authenticate another user to avoid crash in other tests + user = await models.User.findByEmail('mitchell@gmail.com'); + server = constructTestServer({ + context: () => { + return { models, user, auth, db, validation }; + } + }); + }); + + it('updates address without overwriting others fields', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { user: { address } } + }); + expect(res.data.updateProfile.address).toEqual(address); + expect(res.data.updateProfile.email).toBeTruthy(); + }); + + it('updates bank details without overwriting others fields', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { user: { bankDetails } } + }); + expect(res.data.updateProfile.bankDetails).toEqual(bankDetails); + expect(res.data.updateProfile.address).toEqual(address); + }); + + it('updates email without overwritting others fields', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { user: { email: 'johnny@john.com' } } + }); + expect(res.data.updateProfile.email).toEqual('johnny@john.com'); + expect(res.data.updateProfile.address).toEqual(address); + }); + + it('fails if email already exists', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { user: { email: 'johnny@john.com' } } + }); + expect(res.errors).toBeTruthy(); + }); + + it('updates name without overwritting others fields', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { user: { name: 'Johnny John' } } + }); + expect(res.data.updateProfile.name).toEqual('Johnny John'); + expect(res.data.updateProfile.address).toEqual(address); + }); + + it('can update all the fields at once', async () => { + const addr = { + street: 'Next street', + city: 'Next city', + zipCode: 1001, + country: 'Next Country' + }; + const bd = { iban: 'BE098483992', bic: 'BPTOZE223' }; + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { + user: { + email: user.email, + name: user.name, + password: 'testing343', + address: addr, + bankDetails: bd + } + } + }); + expect(res.data.updateProfile.name).toEqual(user.name); + expect(res.data.updateProfile.email).toEqual(user.email); + expect(res.data.updateProfile.address).toEqual(addr); + expect(res.data.updateProfile.bankDetails).toEqual(bd); + }); + }); + describe('claims expenses', () => { let uri; let server; @@ -97,7 +190,7 @@ describe('Authenticated user', () => { beforeAll(() => { server = constructTestServer({ context: () => { - return { models, user: loggedUser, auth, cloudinary, db }; + return { models, user: loggedUser, auth, cloudinary, db, validation }; } }); }); From baf3a8806bf63a497f37f0c0fe97094649472257 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Fri, 3 May 2019 11:19:38 +0200 Subject: [PATCH 18/32] Test invoice upload --- backend/src/graphql/types.js | 2 +- .../src/tests/__snapshots__/e2e.test.js.snap | 46 +++++++++++++++ .../__snapshots__/integration.test.js.snap | 11 ++++ backend/src/tests/e2e.test.js | 57 ++++++++++++++++++- backend/src/tests/graphql/queryStrings.js | 10 ++++ backend/src/tests/integration.test.js | 50 +++++++++++++++- 6 files changed, 172 insertions(+), 4 deletions(-) diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js index dedab97..d732b4d 100644 --- a/backend/src/graphql/types.js +++ b/backend/src/graphql/types.js @@ -18,7 +18,7 @@ module.exports = gql` login(email: String!, password: String!): User! @guest expenseClaim(expense: Expense!): Transaction! @auth updateProfile(user: UserUpdateInput!): User! @auth - uploadInvoice(invoice: InvoiceUpload!): Transaction! + uploadInvoice(invoice: InvoiceUpload!): Transaction! @auth } type Success { status: Boolean! diff --git a/backend/src/tests/__snapshots__/e2e.test.js.snap b/backend/src/tests/__snapshots__/e2e.test.js.snap index 20d2231..1ef94e9 100644 --- a/backend/src/tests/__snapshots__/e2e.test.js.snap +++ b/backend/src/tests/__snapshots__/e2e.test.js.snap @@ -23,6 +23,29 @@ Object { } `; +exports[`Server - e2e Expense claim requires authenticated user 1`] = ` +Object { + "data": null, + "errors": Array [ + Object { + "extensions": Object { + "code": "UNAUTHENTICATED", + }, + "locations": Array [ + Object { + "column": 5, + "line": 3, + }, + ], + "message": "You must be logged in.", + "path": Array [ + "expenseClaim", + ], + }, + ], +} +`; + exports[`Server - e2e Login fails on incorrect email 1`] = ` Object { "data": null, @@ -207,3 +230,26 @@ Object { ], } `; + +exports[`Server - e2e Uplaod invoice requires authenticated user 1`] = ` +Object { + "data": null, + "errors": Array [ + Object { + "extensions": Object { + "code": "UNAUTHENTICATED", + }, + "locations": Array [ + Object { + "column": 5, + "line": 3, + }, + ], + "message": "You must be logged in.", + "path": Array [ + "uploadInvoice", + ], + }, + ], +} +`; diff --git a/backend/src/tests/__snapshots__/integration.test.js.snap b/backend/src/tests/__snapshots__/integration.test.js.snap index b3fc3bb..6bde183 100644 --- a/backend/src/tests/__snapshots__/integration.test.js.snap +++ b/backend/src/tests/__snapshots__/integration.test.js.snap @@ -1,5 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Authenticated user Upload invoice succeeds 1`] = ` +Object { + "data": Object { + "uploadInvoice": Object { + "flow": "IN", + "type": "INVOICE", + }, + }, +} +`; + exports[`Authenticated user claims expenses succeeds 1`] = ` Object { "data": Object { diff --git a/backend/src/tests/e2e.test.js b/backend/src/tests/e2e.test.js index 0a490a6..73ffb54 100644 --- a/backend/src/tests/e2e.test.js +++ b/backend/src/tests/e2e.test.js @@ -1,3 +1,6 @@ +/* eslint-disable no-unused-vars */ +const FormData = require('form-data'); +const fetch = require('node-fetch'); // import our production apollo-server instance const { server, db } = require('../'); @@ -7,7 +10,9 @@ const { LOGIN_ME_IN, REGISTER, ALL_USERS, - UPDATE_PROFILE + UPDATE_PROFILE, + EXPENSE_CLAIM_WITHOUT_GQL, + INVOICE_UPLOAD_WITHOUT_GQL } = require('./graphql/queryStrings'); const testUser = { user: { name: 'Test Test', email: 'tesT@email.com', password: 'testing0189' } }; @@ -17,7 +22,7 @@ const address = { street: 'My street', city: 'My city', zipCode: 1000, country: describe('Server - e2e', () => { let stop; let graphql; - + let uri; beforeAll(async () => { await db.connect(); await populate(); @@ -34,6 +39,8 @@ describe('Server - e2e', () => { stop = testServer.stop; // eslint-disable-next-line prefer-destructuring graphql = testServer.graphql; + // eslint-disable-next-line prefer-destructuring + uri = testServer.uri; }); afterEach(async () => { @@ -160,4 +167,50 @@ describe('Server - e2e', () => { expect(res).toMatchSnapshot(); }); }); + + describe('Expense claim', () => { + it('requires authenticated user', async () => { + // https://github.com/jaydenseric/graphql-upload/issues/125 + + const body = new FormData(); + + body.append( + 'operations', + JSON.stringify({ + query: EXPENSE_CLAIM_WITHOUT_GQL, + variables: { + amount: 10, + description: 'Hello World!', + receipt: null + } + }) + ); + body.append('map', JSON.stringify({ '0': ['variables.receipt'] })); + body.append('0', 'a', { filename: 'a.pdf' }); + let res = await fetch(uri, { method: 'POST', body }); + res = await res.json(); + expect(res).toMatchSnapshot(); + }); + }); + + describe('Uplaod invoice', () => { + it('requires authenticated user', async () => { + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: INVOICE_UPLOAD_WITHOUT_GQL, + variables: { + amount: 10, + invoice: null + } + }) + ); + body.append('map', JSON.stringify({ '0': ['variables.invoice'] })); + body.append('0', 'a', { filename: 'a.pdf' }); + let res = await fetch(uri, { method: 'POST', body }); + res = await res.json(); + expect(res).toMatchSnapshot(); + }); + }); }); diff --git a/backend/src/tests/graphql/queryStrings.js b/backend/src/tests/graphql/queryStrings.js index ab057bf..a991dbd 100644 --- a/backend/src/tests/graphql/queryStrings.js +++ b/backend/src/tests/graphql/queryStrings.js @@ -95,3 +95,13 @@ module.exports.EXPENSE_CLAIM_WITHOUT_GQL = ` } } `; + +module.exports.INVOICE_UPLOAD_WITHOUT_GQL = ` + mutation($amount: Float!, $invoice: Upload!) { + uploadInvoice(invoice: { amount: $amount, invoice: $invoice }) { + id + type + flow + } + } +`; diff --git a/backend/src/tests/integration.test.js b/backend/src/tests/integration.test.js index 879bc3a..6445195 100644 --- a/backend/src/tests/integration.test.js +++ b/backend/src/tests/integration.test.js @@ -12,7 +12,8 @@ const { REGISTER, ALL_USERS, EXPENSE_CLAIM_WITHOUT_GQL, - UPDATE_PROFILE + UPDATE_PROFILE, + INVOICE_UPLOAD_WITHOUT_GQL } = require('./graphql/queryStrings'); const testUser = { user: { name: 'Test Test', email: 'test@email.com', password: 'testing0189' } }; @@ -239,4 +240,51 @@ describe('Authenticated user', () => { expect(res).toMatchSnapshot(); }); }); + + describe('Upload invoice', () => { + let uri; + let server; + let stop; + beforeAll(() => { + server = constructTestServer({ + context: () => { + return { models, user: loggedUser, auth, cloudinary, db, validation }; + } + }); + }); + + beforeEach(async () => { + const testServer = await startTestServer(server); + // eslint-disable-next-line prefer-destructuring + stop = testServer.stop; + // eslint-disable-next-line prefer-destructuring + uri = testServer.uri; + }); + + afterEach(async () => { + stop(); + }); + + it('succeeds', async () => { + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: INVOICE_UPLOAD_WITHOUT_GQL, + variables: { + amount: 10, + invoice: null + } + }) + ); + body.append('map', JSON.stringify({ '0': ['variables.invoice'] })); + const file = fs.createReadStream(`${__dirname}/a.pdf`); + body.append('0', file); + + let res = await fetch(uri, { method: 'POST', body }); + res = await res.json(); + delete res.data.uploadInvoice.id; + expect(res).toMatchSnapshot(); + }); + }); }); From 6a13af8a49b8f5d4dc48dddb80e5dd404714d0a2 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Mon, 6 May 2019 10:41:27 +0200 Subject: [PATCH 19/32] Add constants & improve test a bit --- backend/src/constants.js | 9 ++++ backend/src/graphql/resolvers/mutations.js | 56 +++++++++++----------- backend/src/index.js | 6 ++- backend/src/tests/integration.test.js | 10 ++-- 4 files changed, 48 insertions(+), 33 deletions(-) create mode 100644 backend/src/constants.js diff --git a/backend/src/constants.js b/backend/src/constants.js new file mode 100644 index 0000000..f1b93e2 --- /dev/null +++ b/backend/src/constants.js @@ -0,0 +1,9 @@ +exports.TR_FLOW = { + IN: 'IN', + OUT: 'OUT' +}; + +exports.TR_TYPE = { + INVOICE: 'INVOICE', + EXPENSE: 'EXPENSE' +}; diff --git a/backend/src/graphql/resolvers/mutations.js b/backend/src/graphql/resolvers/mutations.js index a1caea1..d29125e 100644 --- a/backend/src/graphql/resolvers/mutations.js +++ b/backend/src/graphql/resolvers/mutations.js @@ -1,11 +1,3 @@ -const { - validate, - registerValidation, - updateProfileValidation, - expenseValidation, - uploadInvoiceValidation -} = require('../../lib/validation'); - const saveOrRetrieveCompany = async (company, Company) => { if (!company) return null; const { name, id } = company; @@ -33,7 +25,11 @@ const store = (file, tags, folder, cloudinary) => }); module.exports = { - register: async (_, { user }, { models: { User } }) => { + register: async ( + _, + { user }, + { models: { User }, validation: { registerValidation, validate } } + ) => { const { formatData, rules, messages } = registerValidation; await validate(formatData({ ...user }), rules, messages); @@ -48,7 +44,14 @@ module.exports = { expenseClaim: async ( unused, { expense }, - { user, models: { Transaction, User }, cloudinary, db } + { + user, + models: { Transaction, User }, + cloudinary, + db, + constants: { TR_TYPE, TR_FLOW }, + validation: { expenseValidation, validate } + } ) => { expense.date = expense.date || Date.now(); @@ -56,8 +59,8 @@ module.exports = { await validate(formatData({ ...expense }), rules, messages); expense.user = user.id; - expense.flow = 'IN'; - expense.type = 'EXPENSE'; + expense.flow = TR_FLOW.IN; + expense.type = TR_TYPE.EXPENSE; const receipt = await expense.receipt; const session = await db.startSession(); @@ -92,7 +95,11 @@ module.exports = { } return tr; }, - updateProfile: async (_, args, { user, models: { User } }) => { + updateProfile: async ( + _, + args, + { user, models: { User }, validation: { updateProfileValidation, validate } } + ) => { const { formatData, rules, messages } = updateProfileValidation; await validate(formatData({ ...args.user }), rules, messages); const { email } = args.user; @@ -107,7 +114,12 @@ module.exports = { uploadInvoice: async ( root, { invoice }, - { models: { Transaction, Company, Category }, cloudinary } + { + models: { Transaction, Company, Category }, + cloudinary, + constants: { TR_TYPE, TR_FLOW }, + validation: { uploadInvoiceValidation, validate } + } ) => { const { formatData, rules, messages } = uploadInvoiceValidation; await validate(formatData({ ...invoice }), rules, messages); @@ -122,8 +134,8 @@ module.exports = { } invoice.category = category; } - invoice.flow = 'IN'; - invoice.type = 'INVOICE'; + invoice.flow = TR_FLOW.IN; + invoice.type = TR_TYPE.INVOICE; invoice.date = invoice.date || Date.now(); invoice.invoice = await invoice.invoice; @@ -131,17 +143,5 @@ module.exports = { invoice.file = file.secure_url; return new Transaction(invoice).save(); - - // console.log(invoice); } }; - -// TODO REMOVE EXAMPLE -// { -// "query": "mutation ($amount: Float!, $invoice: Upload!) {expenseClaim(expense: {amount: $amount, invoice: $invoice}) {id flow}}", -// "variables": { -// "amount": 10, -// "description": "Hello World!", -// "receipt": null -// } -// } diff --git a/backend/src/index.js b/backend/src/index.js index ec8e876..14dc016 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -16,6 +16,7 @@ const db = require('./db'); const auth = require('./auth'); const validation = require('./lib/validation'); +const constants = require('./constants'); const PORT = process.env.PORT || 4000; @@ -31,7 +32,7 @@ app.use( const context = async ({ req, res }) => { const user = await auth.loggedUser(req.cookies, models); // adopting injection pattern to ease mocking - return { req, res, user, auth, models, cloudinary, db, validation }; + return { req, res, user, auth, models, cloudinary, db, validation, constants }; }; const server = new ApolloServer({ @@ -72,5 +73,6 @@ module.exports = { server, app, cloudinary, - validation + validation, + constants }; diff --git a/backend/src/tests/integration.test.js b/backend/src/tests/integration.test.js index 6445195..b9c3cd7 100644 --- a/backend/src/tests/integration.test.js +++ b/backend/src/tests/integration.test.js @@ -3,7 +3,7 @@ const FormData = require('form-data'); const fetch = require('node-fetch'); const fs = require('fs'); const { auth } = require('./mocks/index'); -const { db, models, cloudinary, validation } = require('../'); +const { db, models, cloudinary, validation, constants } = require('../'); const { constructTestServer, startTestServer, populate, clean } = require('./utils'); const { @@ -191,7 +191,7 @@ describe('Authenticated user', () => { beforeAll(() => { server = constructTestServer({ context: () => { - return { models, user: loggedUser, auth, cloudinary, db, validation }; + return { models, user: loggedUser, auth, cloudinary, db, validation, constants }; } }); }); @@ -235,6 +235,8 @@ describe('Authenticated user', () => { let res = await fetch(uri, { method: 'POST', body }); res = await res.json(); expect(res.data.expenseClaim.user.expenses.includes(res.data.expenseClaim.id)).toBeTruthy(); + expect(res.data.expenseClaim.type).toBe(constants.TR_TYPE.EXPENSE); + expect(res.data.expenseClaim.flow).toBe(constants.TR_FLOW.IN); delete res.data.expenseClaim.id; delete res.data.expenseClaim.user.expenses; expect(res).toMatchSnapshot(); @@ -248,7 +250,7 @@ describe('Authenticated user', () => { beforeAll(() => { server = constructTestServer({ context: () => { - return { models, user: loggedUser, auth, cloudinary, db, validation }; + return { models, user: loggedUser, auth, cloudinary, db, validation, constants }; } }); }); @@ -283,6 +285,8 @@ describe('Authenticated user', () => { let res = await fetch(uri, { method: 'POST', body }); res = await res.json(); + expect(res.data.uploadInvoice.type).toBe(constants.TR_TYPE.INVOICE); + expect(res.data.uploadInvoice.flow).toBe(constants.TR_FLOW.IN); delete res.data.uploadInvoice.id; expect(res).toMatchSnapshot(); }); From 66a9eb6281adf8c706fc1ed2c461c6b03c8c5eae Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Mon, 6 May 2019 17:10:58 +0200 Subject: [PATCH 20/32] Generate an invoice as pdf --- backend/package-lock.json | 256 +++++++++++++++++++++++++++----------- backend/package.json | 1 + backend/src/index.js | 1 + backend/src/invoiceGen.js | 181 +++++++++++++++++++++++++++ 4 files changed, 364 insertions(+), 75 deletions(-) create mode 100644 backend/src/invoiceGen.js diff --git a/backend/package-lock.json b/backend/package-lock.json index d86891c..00b6303 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -707,7 +707,6 @@ "version": "6.10.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz", "integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==", - "dev": true, "requires": { "fast-deep-equal": "^2.0.1", "fast-json-stable-stringify": "^2.0.0", @@ -1126,7 +1125,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dev": true, "requires": { "safer-buffer": "~2.1.0" } @@ -1134,8 +1132,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, "assign-symbols": { "version": "1.0.0", @@ -1185,8 +1182,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "atob": { "version": "2.1.2", @@ -1197,14 +1193,12 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "dev": true + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", - "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", - "dev": true + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" }, "axobject-query": { "version": "2.0.2", @@ -1397,7 +1391,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dev": true, "requires": { "tweetnacl": "^0.14.3" } @@ -1585,8 +1578,7 @@ "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", - "dev": true + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" }, "busboy": { "version": "0.3.1", @@ -1674,8 +1666,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", - "dev": true + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, "chalk": { "version": "2.4.2", @@ -1888,7 +1879,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz", "integrity": "sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -1916,6 +1906,18 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "optional": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "configstore": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/configstore/-/configstore-3.1.2.tgz", @@ -2114,7 +2116,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -2269,8 +2270,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "delegates": { "version": "1.0.0", @@ -2359,7 +2359,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "dev": true, "requires": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -2436,6 +2435,12 @@ "is-symbol": "^1.0.2" } }, + "es6-promise": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.6.tgz", + "integrity": "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q==", + "optional": true + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2913,8 +2918,7 @@ "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "extend-shallow": { "version": "3.0.2", @@ -3024,17 +3028,27 @@ } } }, + "extract-zip": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.7.tgz", + "integrity": "sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=", + "optional": true, + "requires": { + "concat-stream": "1.6.2", + "debug": "2.6.9", + "mkdirp": "0.5.1", + "yauzl": "2.4.1" + } + }, "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", - "dev": true + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" }, "fast-diff": { "version": "1.2.0", @@ -3062,6 +3076,15 @@ "bser": "^2.0.0" } }, + "fd-slicer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", + "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", + "optional": true, + "requires": { + "pend": "~1.2.0" + } + }, "figures": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", @@ -3181,14 +3204,12 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "dev": true + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "form-data": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -3219,6 +3240,17 @@ "resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-2.0.1.tgz", "integrity": "sha512-kyV2oaG1/pu9NPosfGACmBym6okgzyg6hEtA5LSUq0dGpGLe278MVfMwVnSHDA/OBcTCHkPNqWL9eIwbPN6dDg==" }, + "fs-extra": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz", + "integrity": "sha1-zTzl9+fLYUWIP8rjGR6Yd/hYeVA=", + "optional": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^2.1.0", + "klaw": "^1.0.0" + } + }, "fs-minipass": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", @@ -3854,7 +3886,6 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -3951,8 +3982,7 @@ "graceful-fs": { "version": "4.1.15", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", - "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", - "dev": true + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==" }, "graphql": { "version": "14.2.1", @@ -4035,14 +4065,12 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "dev": true + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" }, "har-validator": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", - "dev": true, "requires": { "ajv": "^6.5.5", "har-schema": "^2.0.0" @@ -4113,6 +4141,16 @@ } } }, + "hasha": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-2.2.0.tgz", + "integrity": "sha1-eNfL/B5tZjA/55g3NlmEUXsvbuE=", + "optional": true, + "requires": { + "is-stream": "^1.0.1", + "pinkie-promise": "^2.0.0" + } + }, "hosted-git-info": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", @@ -4128,6 +4166,14 @@ "whatwg-encoding": "^1.0.1" } }, + "html-pdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/html-pdf/-/html-pdf-2.2.0.tgz", + "integrity": "sha1-S8+Rwky1YOR6o/rP0DPg4b8kG5E=", + "requires": { + "phantomjs-prebuilt": "^2.1.4" + } + }, "http-errors": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", @@ -4144,7 +4190,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -4754,8 +4799,7 @@ "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", - "dev": true + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" }, "is-symbol": { "version": "1.0.2", @@ -4768,8 +4812,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, "is-windows": { "version": "1.0.2", @@ -4791,8 +4834,7 @@ "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "isobject": { "version": "3.0.1", @@ -4803,8 +4845,7 @@ "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, "istanbul-api": { "version": "2.1.6", @@ -5521,8 +5562,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "jsdom": { "version": "11.12.0", @@ -5590,14 +5630,12 @@ "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -5608,8 +5646,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json5": { "version": "2.1.0", @@ -5628,6 +5665,15 @@ } } }, + "jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", + "optional": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, "jsonwebtoken": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", @@ -5656,7 +5702,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -5697,12 +5742,27 @@ "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.0.tgz", "integrity": "sha512-6hHxsp9e6zQU8nXsP+02HGWXwTkOEw6IROhF2ZA28cYbUk4eJ6QbtZvdqZOdD9YPKghG3apk5eOCvs+tLl3lRg==" }, + "kew": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/kew/-/kew-0.7.0.tgz", + "integrity": "sha1-edk9LTM2PW/dKXCzNdkUGtWR15s=", + "optional": true + }, "kind-of": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", "dev": true }, + "klaw": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", + "integrity": "sha1-QIhDO0azsbolnXh4XY6W9zugJDk=", + "optional": true, + "requires": { + "graceful-fs": "^4.1.9" + } + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6623,8 +6683,7 @@ "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "dev": true + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" }, "object-assign": { "version": "4.1.1", @@ -6981,11 +7040,41 @@ "pify": "^2.0.0" } }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "optional": true + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", - "dev": true + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "phantomjs-prebuilt": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/phantomjs-prebuilt/-/phantomjs-prebuilt-2.1.16.tgz", + "integrity": "sha1-79ISpKOWbTZHaE6ouniFSb4q7+8=", + "optional": true, + "requires": { + "es6-promise": "^4.0.3", + "extract-zip": "^1.6.5", + "fs-extra": "^1.0.0", + "hasha": "^2.2.0", + "kew": "^0.7.0", + "progress": "^1.1.8", + "request": "^2.81.0", + "request-progress": "^2.0.1", + "which": "^1.2.10" + }, + "dependencies": { + "progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=", + "optional": true + } + } }, "pify": { "version": "2.3.0", @@ -6996,14 +7085,12 @@ "pinkie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", - "dev": true + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" }, "pinkie-promise": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", - "dev": true, "requires": { "pinkie": "^2.0.0" } @@ -7177,8 +7264,7 @@ "psl": { "version": "1.1.31", "resolved": "https://registry.npmjs.org/psl/-/psl-1.1.31.tgz", - "integrity": "sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw==", - "dev": true + "integrity": "sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw==" }, "pstree.remy": { "version": "1.1.6", @@ -7199,8 +7285,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "q": { "version": "1.5.1", @@ -7393,7 +7478,6 @@ "version": "2.88.0", "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", - "dev": true, "requires": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -7420,14 +7504,12 @@ "punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", - "dev": true + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" }, "tough-cookie": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", - "dev": true, "requires": { "psl": "^1.1.24", "punycode": "^1.4.1" @@ -7435,6 +7517,15 @@ } } }, + "request-progress": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-2.0.1.tgz", + "integrity": "sha1-XTa7V5YcZzqlt4jbyBQf3yO0Tgg=", + "optional": true, + "requires": { + "throttleit": "^1.0.0" + } + }, "request-promise-core": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz", @@ -8049,7 +8140,6 @@ "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "dev": true, "requires": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -8492,6 +8582,12 @@ "integrity": "sha1-iQN8vJLFarGJJua6TLsgDhVnKmo=", "dev": true }, + "throttleit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz", + "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=", + "optional": true + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -8640,7 +8736,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -8648,8 +8743,7 @@ "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, "type-check": { "version": "0.3.2", @@ -8669,6 +8763,12 @@ "mime-types": "~2.1.18" } }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "optional": true + }, "uglify-js": { "version": "3.5.8", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.5.8.tgz", @@ -8838,7 +8938,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", - "dev": true, "requires": { "punycode": "^2.1.0" } @@ -8907,7 +9006,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -8979,7 +9077,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, "requires": { "isexe": "^2.0.0" } @@ -9224,6 +9321,15 @@ "decamelize": "^1.2.0" } }, + "yauzl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz", + "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=", + "optional": true, + "requires": { + "fd-slicer": "~1.0.1" + } + }, "yup": { "version": "0.26.10", "resolved": "https://registry.npmjs.org/yup/-/yup-0.26.10.tgz", diff --git a/backend/package.json b/backend/package.json index 011646f..792355c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "dotenv": "^7.0.0", "express": "^4.16.4", "graphql": "^14.1.1", + "html-pdf": "^2.2.0", "indicative": "^5.0.8", "jsonwebtoken": "^8.5.1", "mongoose": "^5.4.19" diff --git a/backend/src/index.js b/backend/src/index.js index 14dc016..d1e6e3b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -17,6 +17,7 @@ const auth = require('./auth'); const validation = require('./lib/validation'); const constants = require('./constants'); +const invoiceGen = require('./invoiceGen'); const PORT = process.env.PORT || 4000; diff --git a/backend/src/invoiceGen.js b/backend/src/invoiceGen.js new file mode 100644 index 0000000..ee16e46 --- /dev/null +++ b/backend/src/invoiceGen.js @@ -0,0 +1,181 @@ +const pdf = require('html-pdf'); + +const options = { format: 'Letter' }; + +const organization = { + name: 'Open Knowledge Belgium vzw', + address: { + street: 'Cantersteen 12', + city: 'Brussel', + country: 'Belgium', + zipCode: 1000 + }, + VAT: 'BE 0845.419.930', + iban: 'BE45 0688 9551 0289' +}; + +const renderContactDetails = contact => { + return `${contact.name}
+ ${contact.address.street}
+ ${contact.address.zipCode} ${contact.address.city} ${contact.address.country}
VAT: ${ + contact.VAT + }`; +}; + +const generateInvoice = /* sender */ (details, metadata, receiver) => { + const total = details.reduce((acc, current) => acc + current.amount, 0); + const amountVAT = (total / 100) * metadata.VAT; + const totalInclVAT = total + amountVAT; + const html = ` + + + + + +
+

Invoice

+
${renderContactDetails(receiver)}
+
+ Invoice: ${metadata.noInvoice}
+ Date: ${new Date(metadata.date).toLocaleDateString('be-BE')} +
+ + + + + + + + + ${details + .map( + detail => ` + + + ` + ) + .join('')} + + + + + + + + + + + + + + + + +
DescriptionAmount
${detail.description}€ ${detail.amount}
Total excl. VAT€ ${total}
VAT ${metadata.VAT}%€ ${amountVAT}
Total€ ${totalInclVAT}
+
Please pay this invoice by bank transfer, clearly stating the invoice number, to our account ${ + organization.iban + } within 30 days after date of invoice clearly. +
+
+
${organization.name}
+ + `; + return new Promise((resolve, reject) => { + pdf.create(html, options).toFile('./businesscard.pdf', (err, res) => { + if (err) return reject(err); + return resolve(res); + }); + }); +}; + +const receiver = { + name: 'PwC Enterprise Advisory cvba,', + address: { + street: 'Woluwe Garden Woluwedal 18', + city: 'Brussel', + country: 'Sint-Stevens-Woluwe', + zipCode: 1932 + }, + VAT: '0415.622.333' +}; + +const details = [ + { + description: 'Purchase of the Gold Partner Package for the Open Belgium Conference 2019', + amount: 3000 + }, + { + description: 'Purchase of the Gold Partner Package for the Open Belgium Conference 2019', + amount: 3000 + }, + { + description: 'Purchase of the Gold Partner Package for the Open Belgium Conference 2019', + amount: 3000 + } +]; +const metadata = { + VAT: 21, + data: Date.now(), + noInvoice: '2019/0005' +}; + +generateInvoice(details, metadata, receiver); From 48fa906e94e41e2c96205e44e59d880f17ca0637 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 11:37:30 +0200 Subject: [PATCH 21/32] Add ref field --- backend/src/graphql/types.js | 1 + backend/src/models/Transaction.js | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js index d732b4d..da913d9 100644 --- a/backend/src/graphql/types.js +++ b/backend/src/graphql/types.js @@ -58,6 +58,7 @@ module.exports = gql` file: String VAT: Int type: TransactionType! + ref: String } type Category { diff --git a/backend/src/models/Transaction.js b/backend/src/models/Transaction.js index 967471f..4fe0353 100644 --- a/backend/src/models/Transaction.js +++ b/backend/src/models/Transaction.js @@ -48,7 +48,8 @@ const transactionSchema = new Schema({ type: { type: String, enum: ['EXPENSE', 'INVOICE'] - } + }, + ref: String }); module.exports = mongoose.model('Transaction', transactionSchema); From 773f9b8402b305e9d7ece678a9e81d35afe53391 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 11:38:33 +0200 Subject: [PATCH 22/32] Create Counter collection --- backend/src/db.js | 1 + backend/src/models/Counter.js | 10 ++++++++++ backend/src/models/index.js | 3 ++- 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 backend/src/models/Counter.js diff --git a/backend/src/db.js b/backend/src/db.js index 25b28b8..dec81a7 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -13,6 +13,7 @@ module.exports.connect = async () => { await mongoose.connection.createCollection('transactions'); await mongoose.connection.createCollection('categories'); await mongoose.connection.createCollection('companies'); + await mongoose.connection.createCollection('counters'); console.log('Collections created successfully!'); }) .catch(err => { diff --git a/backend/src/models/Counter.js b/backend/src/models/Counter.js new file mode 100644 index 0000000..2a335d3 --- /dev/null +++ b/backend/src/models/Counter.js @@ -0,0 +1,10 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const CounterSchema = new Schema({ + _id: String, + sequence: Number +}); + +module.exports = mongoose.model('Counter', CounterSchema); diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 80740c2..d8ccd86 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -2,5 +2,6 @@ const User = require('./User'); const Transaction = require('./Transaction'); const Company = require('./Company'); const Category = require('./Category'); +const Counter = require('./Counter'); -module.exports = { User, Transaction, Company, Category }; +module.exports = { User, Transaction, Company, Category, Counter }; From 29db76488ba258eaddeab3d1c57dd84a32bc1b82 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 11:40:10 +0200 Subject: [PATCH 23/32] Create function to retrieve incremented invoice ref --- backend/src/graphql/resolvers/mutations.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/src/graphql/resolvers/mutations.js b/backend/src/graphql/resolvers/mutations.js index d29125e..539ae41 100644 --- a/backend/src/graphql/resolvers/mutations.js +++ b/backend/src/graphql/resolvers/mutations.js @@ -13,6 +13,17 @@ const saveOrRetrieveCompany = async (company, Company) => { return null; }; +const getInvoiceRef = async (id, Counter) => { + const counter = await Counter.findByIdAndUpdate( + id, + { $inc: { sequence: 1 } }, + { new: true, upsert: true } + ); + const INVOICE_REF_SIZE = process.env.INVOICE_REF_SIZE || 4; + const z = '0'; + return `${id}/${`${z.repeat(INVOICE_REF_SIZE)}${counter.sequence}`.slice(-INVOICE_REF_SIZE)}`; +}; + const store = (file, tags, folder, cloudinary) => new Promise((resolve, reject) => { const uploadStream = cloudinary.uploader.upload_stream({ tags, folder }, (err, image) => { From c7f5eafbd515b580364ee994d56f1468cc45485f Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 11:41:29 +0200 Subject: [PATCH 24/32] Fix typo --- backend/src/invoiceGen.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/invoiceGen.js b/backend/src/invoiceGen.js index ee16e46..014854f 100644 --- a/backend/src/invoiceGen.js +++ b/backend/src/invoiceGen.js @@ -174,7 +174,7 @@ const details = [ ]; const metadata = { VAT: 21, - data: Date.now(), + date: Date.now(), noInvoice: '2019/0005' }; From 1600ab1641a82dd8585e9b961a4b152232e8982d Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 13:51:13 +0200 Subject: [PATCH 25/32] Create types related to invoice generation --- backend/src/graphql/types.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js index da913d9..8e95b8c 100644 --- a/backend/src/graphql/types.js +++ b/backend/src/graphql/types.js @@ -19,6 +19,7 @@ module.exports = gql` expenseClaim(expense: Expense!): Transaction! @auth updateProfile(user: UserUpdateInput!): User! @auth uploadInvoice(invoice: InvoiceUpload!): Transaction! @auth + generateInvoice(invoice: GenerateInvoiceInput!): Transaction! @auth } type Success { status: Boolean! @@ -132,6 +133,17 @@ module.exports = gql` address: AdressInput } + input GenerateInvoiceInput { + company: CompanyInput! + details: [GenerateInvoiceDetailsInput!]! + VAT: Int! + } + + input GenerateInvoiceDetailsInput { + description: String! + amount: Float! + } + #enums enum Flow { IN From b67a6d140c54c035adc02d26f2aa1168314f6bd4 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 13:53:03 +0200 Subject: [PATCH 26/32] Implement generateInvoice resolver & change store function behavior --- backend/src/graphql/resolvers/mutations.js | 39 ++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/backend/src/graphql/resolvers/mutations.js b/backend/src/graphql/resolvers/mutations.js index 539ae41..ce33f90 100644 --- a/backend/src/graphql/resolvers/mutations.js +++ b/backend/src/graphql/resolvers/mutations.js @@ -24,7 +24,7 @@ const getInvoiceRef = async (id, Counter) => { return `${id}/${`${z.repeat(INVOICE_REF_SIZE)}${counter.sequence}`.slice(-INVOICE_REF_SIZE)}`; }; -const store = (file, tags, folder, cloudinary) => +const store = (file, tags, folder, cloudinary, generated) => new Promise((resolve, reject) => { const uploadStream = cloudinary.uploader.upload_stream({ tags, folder }, (err, image) => { if (image) { @@ -32,7 +32,8 @@ const store = (file, tags, folder, cloudinary) => } return reject(err); }); - file.createReadStream().pipe(uploadStream); + if (!generated) file.createReadStream().pipe(uploadStream); + else file.pipe(uploadStream); }); module.exports = { @@ -153,6 +154,40 @@ module.exports = { const file = await store(invoice.invoice, 'invoice', '/invoices/pending', cloudinary); invoice.file = file.secure_url; + return new Transaction(invoice).save(); + }, + generateInvoice: async ( + root, + { invoice }, + { + models: { Transaction, Company, Counter }, + cloudinary, + constants: { TR_TYPE, TR_FLOW }, + validation: { uploadInvoiceValidation, validate }, + invoiceGen + } + ) => { + const company = await saveOrRetrieveCompany(invoice.company, Company); + invoice.company = company; + invoice.type = TR_TYPE.INVOICE; + invoice.flow = TR_FLOW.OUT; + invoice.date = Date.now(); + + const noInvoice = await getInvoiceRef(new Date(invoice.date).getFullYear(), Counter); + + let file = await invoiceGen( + invoice.details, + { + date: invoice.date, + VAT: invoice.VAT, + noInvoice + }, + company + ); + + file = await store(file, 'invoice', '/invoices/pending', cloudinary, true); + invoice.file = file.secure_url; + return new Transaction(invoice).save(); } }; From e583b3b7e0e3886c040fddfd3815754cd0549436 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 13:53:40 +0200 Subject: [PATCH 27/32] Modify pdf template --- backend/src/index.js | 2 +- backend/src/invoiceGen.js | 53 ++++++++------------------------------- 2 files changed, 12 insertions(+), 43 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index d1e6e3b..de4410e 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -33,7 +33,7 @@ app.use( const context = async ({ req, res }) => { const user = await auth.loggedUser(req.cookies, models); // adopting injection pattern to ease mocking - return { req, res, user, auth, models, cloudinary, db, validation, constants }; + return { req, res, user, auth, models, cloudinary, db, validation, constants, invoiceGen }; }; const server = new ApolloServer({ diff --git a/backend/src/invoiceGen.js b/backend/src/invoiceGen.js index 014854f..a736234 100644 --- a/backend/src/invoiceGen.js +++ b/backend/src/invoiceGen.js @@ -11,7 +11,8 @@ const organization = { zipCode: 1000 }, VAT: 'BE 0845.419.930', - iban: 'BE45 0688 9551 0289' + iban: 'BE45 0688 9551 0289', + logo: 'https://res.cloudinary.com/dwyxk1pns/image/upload/v1557228337/assets/organization-logo.png' }; const renderContactDetails = contact => { @@ -22,7 +23,7 @@ const renderContactDetails = contact => { }`; }; -const generateInvoice = /* sender */ (details, metadata, receiver) => { +module.exports = /* sender */ (details, metadata, receiver) => { const total = details.reduce((acc, current) => acc + current.amount, 0); const amountVAT = (total / 100) * metadata.VAT; const totalInclVAT = total + amountVAT; @@ -64,15 +65,16 @@ const generateInvoice = /* sender */ (details, metadata, receiver) => { table { font-size: inherit; border-collapse: collapse; - } - - th:last-child, td:last-child { - text-align: right; width: 100%; } th:first-child, td:first-child { - width: 80%; + width: 80%%; + } + + th:last-child, td:last-child { + text-align: right; + width: 20%; } th, td { @@ -89,7 +91,7 @@ const generateInvoice = /* sender */ (details, metadata, receiver) => {

Invoice

@@ -140,42 +142,9 @@ const generateInvoice = /* sender */ (details, metadata, receiver) => { `; return new Promise((resolve, reject) => { - pdf.create(html, options).toFile('./businesscard.pdf', (err, res) => { + pdf.create(html, options).toStream((err, res) => { if (err) return reject(err); return resolve(res); }); }); }; - -const receiver = { - name: 'PwC Enterprise Advisory cvba,', - address: { - street: 'Woluwe Garden Woluwedal 18', - city: 'Brussel', - country: 'Sint-Stevens-Woluwe', - zipCode: 1932 - }, - VAT: '0415.622.333' -}; - -const details = [ - { - description: 'Purchase of the Gold Partner Package for the Open Belgium Conference 2019', - amount: 3000 - }, - { - description: 'Purchase of the Gold Partner Package for the Open Belgium Conference 2019', - amount: 3000 - }, - { - description: 'Purchase of the Gold Partner Package for the Open Belgium Conference 2019', - amount: 3000 - } -]; -const metadata = { - VAT: 21, - date: Date.now(), - noInvoice: '2019/0005' -}; - -generateInvoice(details, metadata, receiver); From 49eeb344a8bb71f2fad88c5e7b016848b15f40a0 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 15:35:59 +0200 Subject: [PATCH 28/32] Add generateInvoiceInputValidation & refine some resolvers --- backend/src/constants.js | 9 +++++ backend/src/graphql/resolvers/mutations.js | 40 ++++++++++++++-------- backend/src/lib/validation.js | 18 ++++++++++ 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/backend/src/constants.js b/backend/src/constants.js index f1b93e2..3a61baa 100644 --- a/backend/src/constants.js +++ b/backend/src/constants.js @@ -7,3 +7,12 @@ exports.TR_TYPE = { INVOICE: 'INVOICE', EXPENSE: 'EXPENSE' }; + +exports.STORAGE_PATH = { + INVOICE_PENDING: '/invoices/pending', + INVOICE_PAID: '/invoices/paid', + INVOICE_REJECTED: '/invoices/rejected', + EXPENSE_PENDING: '/expenses/pending', + EXPENSE_PAID: '/expenses/paid', + EXPENSE_REJECTED: '/expenses/rejected' +}; diff --git a/backend/src/graphql/resolvers/mutations.js b/backend/src/graphql/resolvers/mutations.js index ce33f90..1038d2f 100644 --- a/backend/src/graphql/resolvers/mutations.js +++ b/backend/src/graphql/resolvers/mutations.js @@ -1,4 +1,4 @@ -const saveOrRetrieveCompany = async (company, Company) => { +const saveOrRetrieveCompany = async (company, Company, upsert) => { if (!company) return null; const { name, id } = company; if (!name && !id) return null; @@ -6,9 +6,7 @@ const saveOrRetrieveCompany = async (company, Company) => { return Company.findById(id); } if (company) { - const cmpny = await Company.findOne({ name }); - if (cmpny) return cmpny; - return new Company(company).save(); + return Company.findOneAndUpdate({ name }, company, { new: true, upsert }); } return null; }; @@ -61,7 +59,7 @@ module.exports = { models: { Transaction, User }, cloudinary, db, - constants: { TR_TYPE, TR_FLOW }, + constants: { TR_TYPE, TR_FLOW, STORAGE_PATH }, validation: { expenseValidation, validate } } ) => { @@ -79,7 +77,12 @@ module.exports = { let tr; try { // upload the file to cloudinary - const file = await store(receipt, 'expense receipt', '/expenses/pending/', cloudinary); + const file = await store( + receipt, + 'expense receipt', + STORAGE_PATH.EXPENSE_PENDING, + cloudinary + ); expense.file = file.secure_url; const opts = { session }; @@ -129,13 +132,15 @@ module.exports = { { models: { Transaction, Company, Category }, cloudinary, - constants: { TR_TYPE, TR_FLOW }, + constants: { TR_TYPE, TR_FLOW, STORAGE_PATH }, validation: { uploadInvoiceValidation, validate } } ) => { const { formatData, rules, messages } = uploadInvoiceValidation; await validate(formatData({ ...invoice }), rules, messages); - const company = await saveOrRetrieveCompany(invoice.company, Company); + + // look for a company (by id or name), if name is provided: update it or save it if doesn't exist + const company = await saveOrRetrieveCompany(invoice.company, Company, true); invoice.company = company; if (invoice.category && (invoice.category.name || invoice.category.id)) { const category = Category.findOne({ @@ -151,7 +156,7 @@ module.exports = { invoice.date = invoice.date || Date.now(); invoice.invoice = await invoice.invoice; - const file = await store(invoice.invoice, 'invoice', '/invoices/pending', cloudinary); + const file = await store(invoice.invoice, 'invoice', STORAGE_PATH.INVOICE_PENDING, cloudinary); invoice.file = file.secure_url; return new Transaction(invoice).save(); @@ -162,18 +167,25 @@ module.exports = { { models: { Transaction, Company, Counter }, cloudinary, - constants: { TR_TYPE, TR_FLOW }, - validation: { uploadInvoiceValidation, validate }, + constants: { TR_TYPE, TR_FLOW, STORAGE_PATH }, + validation: { generateInvoiceValidation, validate }, invoiceGen } ) => { - const company = await saveOrRetrieveCompany(invoice.company, Company); + const { formatData, rules, messages } = generateInvoiceValidation; + + // look for a company (by id or name), if name is provided: update it or save it if doesn't exist + const company = await saveOrRetrieveCompany(invoice.company, Company, true); invoice.company = company; + + // validate the inputs with the retrieved company, will throw if any required element is missing + await validate(formatData(invoice), rules, messages); invoice.type = TR_TYPE.INVOICE; invoice.flow = TR_FLOW.OUT; invoice.date = Date.now(); const noInvoice = await getInvoiceRef(new Date(invoice.date).getFullYear(), Counter); + invoice.ref = noInvoice; let file = await invoiceGen( invoice.details, @@ -182,10 +194,10 @@ module.exports = { VAT: invoice.VAT, noInvoice }, - company + invoice.company ); - file = await store(file, 'invoice', '/invoices/pending', cloudinary, true); + file = await store(file, 'invoice', STORAGE_PATH.INVOICE_PENDING, cloudinary, true); invoice.file = file.secure_url; return new Transaction(invoice).save(); diff --git a/backend/src/lib/validation.js b/backend/src/lib/validation.js index 71f3dd9..dfbed19 100644 --- a/backend/src/lib/validation.js +++ b/backend/src/lib/validation.js @@ -187,3 +187,21 @@ exports.uploadInvoiceValidation = { }, formatData: data => ({ ...data, company: companyValidation.formatData({ ...data.company }) }) }; + +exports.generateInvoiceValidation = { + rules: { + 'details.*.description': `required|max:${MAX_LENGTH}`, + 'details.*.amount': `above:-1|max:${MAX_LENGTH}`, + VAT: 'above:-1', + 'company.name': `required|max:${MAX_LENGTH}` + }, + messages: { + above: aboveMessage, + max: maxMessage, + required: requiredMessage + }, + formatData: data => { + data.details = data.details.map(detail => ({ ...detail })); + return { ...data, details: [...data.details], company: data.company.toJSON() }; + } +}; From 1f55baad4a0900dba21e60eff17560fc34728a49 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 15:43:14 +0200 Subject: [PATCH 29/32] Clean Company & Counter test data --- backend/src/tests/utils.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/tests/utils.js b/backend/src/tests/utils.js index bb3b5c3..a21d16c 100644 --- a/backend/src/tests/utils.js +++ b/backend/src/tests/utils.js @@ -85,9 +85,11 @@ module.exports.startTestServer = startTestServer; // clean database const clean = async () => { - const { User, Transaction } = models; + const { User, Transaction, Company, Counter } = models; await User.deleteMany({}); await Transaction.deleteMany({}); + await Company.deleteMany({}); + await Counter.deleteMany({}); }; // populate database From ed81504bbf7b7b2c35980bb2e34365df19063169 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 16:02:04 +0200 Subject: [PATCH 30/32] Test generate invoice --- backend/src/index.js | 3 +- .../src/tests/__snapshots__/e2e.test.js.snap | 23 ++++++++ .../__snapshots__/integration.test.js.snap | 18 ++++++ backend/src/tests/e2e.test.js | 25 +++++++- backend/src/tests/graphql/queryStrings.js | 11 ++++ backend/src/tests/integration.test.js | 59 ++++++++++++++++++- 6 files changed, 135 insertions(+), 4 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index de4410e..db1c7a7 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -75,5 +75,6 @@ module.exports = { app, cloudinary, validation, - constants + constants, + invoiceGen }; diff --git a/backend/src/tests/__snapshots__/e2e.test.js.snap b/backend/src/tests/__snapshots__/e2e.test.js.snap index 1ef94e9..2bf1242 100644 --- a/backend/src/tests/__snapshots__/e2e.test.js.snap +++ b/backend/src/tests/__snapshots__/e2e.test.js.snap @@ -46,6 +46,29 @@ Object { } `; +exports[`Server - e2e Generate invoice requires authenticated user 1`] = ` +Object { + "data": null, + "errors": Array [ + Object { + "extensions": Object { + "code": "UNAUTHENTICATED", + }, + "locations": Array [ + Object { + "column": 3, + "line": 2, + }, + ], + "message": "You must be logged in.", + "path": Array [ + "generateInvoice", + ], + }, + ], +} +`; + exports[`Server - e2e Login fails on incorrect email 1`] = ` Object { "data": null, diff --git a/backend/src/tests/__snapshots__/integration.test.js.snap b/backend/src/tests/__snapshots__/integration.test.js.snap index 6bde183..5edb6e8 100644 --- a/backend/src/tests/__snapshots__/integration.test.js.snap +++ b/backend/src/tests/__snapshots__/integration.test.js.snap @@ -1,5 +1,23 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Authenticated user Generate invoice succeeds 1`] = ` +Object { + "data": Object { + "generateInvoice": Object { + "flow": "OUT", + "type": "INVOICE", + }, + }, + "errors": undefined, + "extensions": undefined, + "http": Object { + "headers": Headers { + Symbol(map): Object {}, + }, + }, +} +`; + exports[`Authenticated user Upload invoice succeeds 1`] = ` Object { "data": Object { diff --git a/backend/src/tests/e2e.test.js b/backend/src/tests/e2e.test.js index 73ffb54..daaf91f 100644 --- a/backend/src/tests/e2e.test.js +++ b/backend/src/tests/e2e.test.js @@ -12,7 +12,8 @@ const { ALL_USERS, UPDATE_PROFILE, EXPENSE_CLAIM_WITHOUT_GQL, - INVOICE_UPLOAD_WITHOUT_GQL + INVOICE_UPLOAD_WITHOUT_GQL, + GENERATE_INVOICE } = require('./graphql/queryStrings'); const testUser = { user: { name: 'Test Test', email: 'tesT@email.com', password: 'testing0189' } }; @@ -213,4 +214,26 @@ describe('Server - e2e', () => { expect(res).toMatchSnapshot(); }); }); + + describe('Generate invoice', () => { + it('requires authenticated user', async () => { + const res = await toPromise( + graphql({ + query: GENERATE_INVOICE, + variables: { + invoice: { + VAT: 21, + company: { + name: 'MY SUPER COMP', + VAT: 'MY VAT', + address: { street: '', country: '', city: '', zipCode: 0 } + }, + details: [{ description: '', amount: 200 }] + } + } + }) + ); + expect(res).toMatchSnapshot(); + }); + }); }); diff --git a/backend/src/tests/graphql/queryStrings.js b/backend/src/tests/graphql/queryStrings.js index a991dbd..5b36a9a 100644 --- a/backend/src/tests/graphql/queryStrings.js +++ b/backend/src/tests/graphql/queryStrings.js @@ -105,3 +105,14 @@ module.exports.INVOICE_UPLOAD_WITHOUT_GQL = ` } } `; + +module.exports.GENERATE_INVOICE = gql` + mutation($invoice: GenerateInvoiceInput!) { + generateInvoice(invoice: $invoice) { + id + type + flow + ref + } + } +`; diff --git a/backend/src/tests/integration.test.js b/backend/src/tests/integration.test.js index b9c3cd7..00c6739 100644 --- a/backend/src/tests/integration.test.js +++ b/backend/src/tests/integration.test.js @@ -3,7 +3,7 @@ const FormData = require('form-data'); const fetch = require('node-fetch'); const fs = require('fs'); const { auth } = require('./mocks/index'); -const { db, models, cloudinary, validation, constants } = require('../'); +const { db, models, cloudinary, validation, constants, invoiceGen } = require('../'); const { constructTestServer, startTestServer, populate, clean } = require('./utils'); const { @@ -13,7 +13,8 @@ const { ALL_USERS, EXPENSE_CLAIM_WITHOUT_GQL, UPDATE_PROFILE, - INVOICE_UPLOAD_WITHOUT_GQL + INVOICE_UPLOAD_WITHOUT_GQL, + GENERATE_INVOICE } = require('./graphql/queryStrings'); const testUser = { user: { name: 'Test Test', email: 'test@email.com', password: 'testing0189' } }; @@ -184,6 +185,60 @@ describe('Authenticated user', () => { }); }); + describe('Generate invoice', () => { + let server; + let stop; + beforeAll(() => { + server = constructTestServer({ + context: () => { + return { + models, + user: loggedUser, + auth, + cloudinary, + db, + validation, + constants, + invoiceGen + }; + } + }); + }); + + beforeEach(async () => { + const testServer = await startTestServer(server); + // eslint-disable-next-line prefer-destructuring + stop = testServer.stop; + }); + + afterEach(async () => { + stop(); + }); + + it('succeeds', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: GENERATE_INVOICE, + variables: { + invoice: { + VAT: 21, + company: { + name: 'MY SUPER COMP', + VAT: 'MY VAT', + address + }, + details: [{ description: 'My description', amount: 200 }] + } + } + }); + expect(res.data.generateInvoice.id).toBeTruthy(); + expect(res.data.generateInvoice.ref).toBeTruthy(); + delete res.data.generateInvoice.id; + delete res.data.generateInvoice.ref; + expect(res).toMatchSnapshot(); + }); + }); + describe('claims expenses', () => { let uri; let server; From 1511e2c446618adc710368314dd6b74e23dd7ba7 Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 16:45:24 +0200 Subject: [PATCH 31/32] Test generate invoice validation --- backend/src/lib/validation.js | 6 +- .../__snapshots__/validations.test.js.snap | 44 ++++++++++++ backend/src/tests/validations.test.js | 72 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 backend/src/tests/__snapshots__/validations.test.js.snap diff --git a/backend/src/lib/validation.js b/backend/src/lib/validation.js index dfbed19..ea2fe16 100644 --- a/backend/src/lib/validation.js +++ b/backend/src/lib/validation.js @@ -193,7 +193,11 @@ exports.generateInvoiceValidation = { 'details.*.description': `required|max:${MAX_LENGTH}`, 'details.*.amount': `above:-1|max:${MAX_LENGTH}`, VAT: 'above:-1', - 'company.name': `required|max:${MAX_LENGTH}` + 'company.name': `required|max:${MAX_LENGTH}`, + 'company.address.street': `required|max:${MAX_LENGTH}`, + 'company.address.city': `required|max:${MAX_LENGTH}`, + 'company.address.zipCode': `required`, + 'company.address.country': `required|max:${MAX_LENGTH}` }, messages: { above: aboveMessage, diff --git a/backend/src/tests/__snapshots__/validations.test.js.snap b/backend/src/tests/__snapshots__/validations.test.js.snap new file mode 100644 index 0000000..a8b1dbb --- /dev/null +++ b/backend/src/tests/__snapshots__/validations.test.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GENERATE INVOICE VALIDATION fails max length exceeded 1`] = ` +Array [ + Object { + "details.0.description": "[details.*.description] must have a maximum of 50 characters.", + }, + Object { + "company.name": "[company.name] must have a maximum of 50 characters.", + }, + Object { + "company.address.street": "[company.address.street] must have a maximum of 50 characters.", + }, + Object { + "company.address.city": "[company.address.city] must have a maximum of 50 characters.", + }, + Object { + "company.address.country": "[company.address.country] must have a maximum of 50 characters.", + }, +] +`; + +exports[`GENERATE INVOICE VALIDATION fails on missing required fields 1`] = ` +Array [ + Object { + "details.0.description": "[details.*.description] is required (cannot be empty)", + }, + Object { + "company.name": "[company.name] is required (cannot be empty)", + }, + Object { + "company.address.street": "[company.address.street] is required (cannot be empty)", + }, + Object { + "company.address.city": "[company.address.city] is required (cannot be empty)", + }, + Object { + "company.address.zipCode": "[company.address.zipCode] is required (cannot be empty)", + }, + Object { + "company.address.country": "[company.address.country] is required (cannot be empty)", + }, +] +`; diff --git a/backend/src/tests/validations.test.js b/backend/src/tests/validations.test.js index 9454dbf..42f0d4b 100644 --- a/backend/src/tests/validations.test.js +++ b/backend/src/tests/validations.test.js @@ -5,6 +5,7 @@ const { updateProfileValidation, expenseValidation, uploadInvoiceValidation, + generateInvoiceValidation, validate } } = require('../'); @@ -30,6 +31,13 @@ const bankDetails = { }; const FIFTYONECHARSSTR = 'QYE5TOXWrDbi0bSQDbM1KmKOljjR5SihgUJO7aDwkkjUJVJOzk6'; +const addressExceedingMaxLength = { + street: FIFTYONECHARSSTR, + city: FIFTYONECHARSSTR, + country: FIFTYONECHARSSTR, + zipCode: 1000 +}; + describe('EXPENSE CLAIM VALIDATION', () => { const { messages, rules } = expenseValidation; const expense = { @@ -388,3 +396,67 @@ describe('UPLOAD INVOICE VALIDATION', () => { }); }); }); + +describe('GENERATE INVOICE VALIDATION', () => { + const { messages, rules } = generateInvoiceValidation; + const invoice = { + VAT: 21, + company: { + name: 'MY SUPER COMP', + VAT: 'MY VAT', + address + }, + details: [{ description: 'My description', amount: 200 }] + }; + it('passes validation', async () => { + await validate(invoice, rules, messages); + }); + it('fails on missing required fields', async () => { + try { + await validate({ details: [{}] }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + expect(JSON.parse(error.message)).toMatchSnapshot(); + } else throw error; + } + }); + it('fails on negative value', async () => { + try { + await validate( + { ...invoice, VAT: -1, details: [{ description: 'My description', amount: -1 }] }, + rules, + messages + ); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const message = JSON.parse(error.message); + expect(message[1].VAT).toBeTruthy(); + expect(message[0]['details.0.amount']).toBeTruthy(); + } else throw error; + } + }); + it('fails max length exceeded', async () => { + try { + await validate( + { + VAT: 10, + details: [{ description: FIFTYONECHARSSTR, amount: 300 }], + company: { + name: FIFTYONECHARSSTR, + address: addressExceedingMaxLength, + VAT: FIFTYONECHARSSTR + } + }, + rules, + messages + ); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + expect(JSON.parse(error.message)).toMatchSnapshot(); + } else throw error; + } + }); +}); From 7650f436ca2dd235fe653d859d3b1f03d8ea55cf Mon Sep 17 00:00:00 2001 From: Ismaila Abdoulahi Date: Tue, 7 May 2019 16:50:08 +0200 Subject: [PATCH 32/32] Change filename invoiceGen -> invoiceFactory & import name invoiceGen -> generateInvoicePDF --- backend/src/graphql/resolvers/mutations.js | 4 ++-- backend/src/index.js | 17 ++++++++++++++--- .../src/{invoiceGen.js => invoiceFactory.js} | 0 backend/src/tests/integration.test.js | 4 ++-- 4 files changed, 18 insertions(+), 7 deletions(-) rename backend/src/{invoiceGen.js => invoiceFactory.js} (100%) diff --git a/backend/src/graphql/resolvers/mutations.js b/backend/src/graphql/resolvers/mutations.js index 1038d2f..edf5505 100644 --- a/backend/src/graphql/resolvers/mutations.js +++ b/backend/src/graphql/resolvers/mutations.js @@ -169,7 +169,7 @@ module.exports = { cloudinary, constants: { TR_TYPE, TR_FLOW, STORAGE_PATH }, validation: { generateInvoiceValidation, validate }, - invoiceGen + generateInvoicePDF } ) => { const { formatData, rules, messages } = generateInvoiceValidation; @@ -187,7 +187,7 @@ module.exports = { const noInvoice = await getInvoiceRef(new Date(invoice.date).getFullYear(), Counter); invoice.ref = noInvoice; - let file = await invoiceGen( + let file = await generateInvoicePDF( invoice.details, { date: invoice.date, diff --git a/backend/src/index.js b/backend/src/index.js index db1c7a7..bede14d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -17,7 +17,7 @@ const auth = require('./auth'); const validation = require('./lib/validation'); const constants = require('./constants'); -const invoiceGen = require('./invoiceGen'); +const generateInvoicePDF = require('./invoiceFactory'); const PORT = process.env.PORT || 4000; @@ -33,7 +33,18 @@ app.use( const context = async ({ req, res }) => { const user = await auth.loggedUser(req.cookies, models); // adopting injection pattern to ease mocking - return { req, res, user, auth, models, cloudinary, db, validation, constants, invoiceGen }; + return { + req, + res, + user, + auth, + models, + cloudinary, + db, + validation, + constants, + generateInvoicePDF + }; }; const server = new ApolloServer({ @@ -76,5 +87,5 @@ module.exports = { cloudinary, validation, constants, - invoiceGen + generateInvoicePDF }; diff --git a/backend/src/invoiceGen.js b/backend/src/invoiceFactory.js similarity index 100% rename from backend/src/invoiceGen.js rename to backend/src/invoiceFactory.js diff --git a/backend/src/tests/integration.test.js b/backend/src/tests/integration.test.js index 00c6739..ac6346a 100644 --- a/backend/src/tests/integration.test.js +++ b/backend/src/tests/integration.test.js @@ -3,7 +3,7 @@ const FormData = require('form-data'); const fetch = require('node-fetch'); const fs = require('fs'); const { auth } = require('./mocks/index'); -const { db, models, cloudinary, validation, constants, invoiceGen } = require('../'); +const { db, models, cloudinary, validation, constants, generateInvoicePDF } = require('../'); const { constructTestServer, startTestServer, populate, clean } = require('./utils'); const { @@ -199,7 +199,7 @@ describe('Authenticated user', () => { db, validation, constants, - invoiceGen + generateInvoicePDF }; } });