npm i io-ts fp-ts
// Users.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { Users } from './Users';
test('renders Users', () => {
const users = [
{id: 3, name: 'Maximiliano', email: 'maximilianou@gmail.com', website: 'https://github.com/maximilianou',
address: { street: 'Roca', suite: 'D', city: 'Buenos Aires', geo: { lat: '-33', lng: '44'}}},
{id: 5, name: 'Joaquin', email: 'jou@gmail.com', website: 'https://github.com/joaquin',
address: { street: '', suite: '', city: '', geo: { lat: '3', lng: '4'}}},
{id: 7, name: 'Julian', email: 'juu@gmail.com', website: 'https://github.com/julian',
address: { street: '', suite: '', city: '', geo: { lat: '1', lng: '2'}}},
];
render(<Users users={users} />);
const elemMax = screen.getAllByText(/Maximiliano/g);
expect(elemMax[0]).toBeInTheDocument();
const elemJoa = screen.getAllByText(/Joaquin/g);
expect(elemJoa[0]).toBeInTheDocument();
const elemJul = screen.getAllByText(/Julian/g);
expect(elemJul[0]).toBeInTheDocument();
});
// Users.tsx
import { isRight } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
const Address = t.type({
street: t.string,
suite: t.string,
city: t.string,
geo: t.type({
lat: t.string,
lng: t.string
}),
})
const User = t.type({
id: t.number,
name: t.string,
email: t.string,
website: t.string,
address : Address,
})
type AddressType = {
street: string,
suite: string,
city: string,
geo: {
lat: string,
lng: string
}
}
type UserType = {
id: number,
name: string,
email: string,
website: string,
address: AddressType
}
type UsersProps = {
users: UserType[]
}
export const Users: React.FC<UsersProps> = ( { users } ) => (
<>
<ul>
{users.map( (u) =>
( isRight(User.decode(u)) && <li key={u.id}>({u.id}) <span>{u.name}</span>, {u.email}</li>)
||
( !isRight(User.decode(u)) && <li key={u.id}>Not Matching {u.email}</li>)
)}
</ul>
</>
)
- Create API project structure
# Makefile
create-api:
mkdir api && cd api && npm -y init
cd api && npm i express-openapi-validator
cd api && npm i @types/node typescript
cd api && npm install ts-node -D
cd api && ./node_modules/.bin/tsc --init --rootDir src --outDir ./bin --esModuleInterop --lib ES2019 --module commonjs --noImplicitAny true
cd api && mkdir src
cd api && echo "console.log('Running.. TypeScript app')" > src/app.ts
cd api && ./node_modules/.bin/tsc
cd api && node ./bin/app.js
- Run initial package.json
"scripts": {
"build": "./node_modules/.bin/tsc ",
"start": "node ./bin/app.js ",
"dev": "./node_modules/.bin/ts-node ./src/app.ts ",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/node": "^14.14.16",
"express-openapi-validator": "^4.10.1",
"typescript": "^4.1.3"
},
"devDependencies": {
"ts-node": "^9.1.1"
}
//server.ts
import express from 'express'
import {Express} from 'express-serve-static-core'
export async function createServer(): Promise<Express> {
const server = express()
server.get('/', (req, res) => {
res.send('Hello world!!!')
})
return server
}
//app.ts
import {createServer} from './utils/server'
createServer()
.then(server => {
server.listen(3000, () => {
console.info(`Listening on http://localhost:3000`)
})
})
.catch(err => {
console.error(`Error: ${err}`)
})
# config/openapi.yml
openapi: 3.0.3
info:
title: API example
description: API example declaration
termsOfService: http://swagger.io/term/
contact:
email: maximilianou@gmail.com
license:
name: MIT
url: https://opensource.org/license/MIT
version: 1.0.0
externalDocs:
description: Find out more about Swagger
url: http://swagger.io
servers:
- url: /api/v1
tags:
- name: greeting
description: Greeting APIs
paths:
/hello:
get:
description: Return message to the caller
tags:
- greeting
operationId: hello
parameters:
- name: name
required: false
in: query
description: The name of the caller
schema:
type: string
responses:
200:
description: success
content:
application/json:
schema:
$ref: '#/components/schemas/HelloResponse'
components:
schemas:
HelloResponse:
type: object
additionalProperties: false
required:
- message
properties:
message:
type: string
//types/swagger-routes-express/index.d.ts
declare module 'swagger-routes-express'
create-api:
mkdir api && cd api && npm -y init
cd api && npm i express-openapi-validator
cd api && npm i @types/node typescript
cd api && npm install ts-node -D
cd api && ./node_modules/.bin/tsc --init --rootDir src --outDir ./bin --esModuleInterop --lib ES2019 --module commonjs --noImplicitAny true
cd api && mkdir src
cd api && echo "console.log('Running.. TypeScript app')" > src/app.ts
cd api && ./node_modules/.bin/tsc
cd api && node ./bin/app.js
cd api && npm i express @types/express
cd api && npm i connect express-openapi-validator swagger-routes-express validator yamljs @types/validator @types/yamljs
cd api && npm i swagger-ui-express @types/swagger-ui-express
// src/api/controllers/greeting.ts
import * as express from 'express'
export function hello(req: express.Request, res: express.Response): void {
const name = req.query.name || 'stranger'
const message = `Hello, ${name}!`
res.json({
"message": message
})
}
// src/utils/server.ts
import express from 'express';
import { Express } from 'express-serve-static-core'
import * as OpenApiValidator from 'express-openapi-validator';
import { connector, summarise} from 'swagger-routes-express';
import YAML from 'yamljs';
import swaggerUi from 'swagger-ui-express';
import * as api from '../api/controllers/greeting';
export async function createServer(): Promise<Express> {
const yamlSpecFile = './config/openapi.yml';
const apiDefinition = YAML.load(yamlSpecFile);
const apiSummary = summarise(apiDefinition);
const server = express();
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDefinition));
const validatorOprions = {
coerceType: true,
apiSpec: yamlSpecFile,
validateRequests: true,
validateResponses: true
}
server.use(OpenApiValidator.middleware(validatorOprions));
server.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
res.status(err.status).json({
error: {
type: 'request_validation',
message: err.message,
errors: err.errors
}
});
});
const connect = connector(api, apiDefinition, {
onCreateRoute: (method: string, descriptor: any[]) => {
console.log(`${method}: ${descriptor[0]} : ${(descriptor[1] as any).name}`);
}
});
connect(server);
return server;
}
npm run dev
curl http://localhost:3021/api/v1/hello
{"message":"Hello, stranger"}
curl http://localhost:3021/api/v1/hello?name=Max
{"message":"Hello, Max"}
maximilianou@instrument:~/projects/weekly22$ cd api/
maximilianou@instrument:~/projects/weekly22/api$ mkdir -p src/api/services
maximilianou@instrument:~/projects/weekly22/api$ touch src/api/services/user.ts
maximilianou@instrument:~/projects/weekly22/api$ touch src/api/controllers/user.ts
maximilianou@instrument:~/projects/weekly22/api$ mkdir -p src/api/utils
maximilianou@instrument:~/projects/weekly22/api$ touch src/api/utils/express.ts
// src/services/user.ts
export type ErrorResponse = { error: {type: string, message: string}}
export type AuthResponse = ErrorResponse | {userId: string}
function auth(bearerToken: string): Promise<AuthResponse>{
return new Promise(function(resolve, reject){
const token = bearerToken.replace('Bearer','');
if(token === 'fakeToken'){
resolve({userId: 'fakeTokenId'});
return;
}
resolve({error: {type: 'unauthorized', message: 'Authorization Failed'}});
});
}
export default { auth: auth };
api/
npm i tsconfig-paths
package.json
{
"start": "node -r tsconfig-paths/register ./bin/app.js ",
"dev": "./node_modules/.bin/ts-node -r tsconfig-paths/register ./src/app.ts ",
}
tsconfig.json
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
"paths": {
"@exmpl/*": ["src/*","bin/*"]
}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
npm i morgan morgan-body
npm i -D @types/morgan
// api/utils/express_dev_logger.ts
import express from 'express';
export const expressDevLogger =
(req: express.Request, res: express.Response, next: express.NextFunction): void => {
const startHrTime = process.hrtime();
console.log(`Request: ${req.method} ${req.url} at ${new Date().toUTCString()}, User-Agent: ${req.get('User-Agent')}`);
console.log(`Request Body: ${JSON.stringify(req.body)}`);
const [oldWrite, oldEnd] = [res.write, res.end];
const chunks: Buffer[] = [];
(res.write as unknown) = function(chunk: any): void {
chunks.push(Buffer.from(chunk));
(oldWrite as Function).apply(res, arguments);
}
res.end = function(chunk: any): any {
if(chunk){
chunks.push(Buffer.from(chunk));
}
const elapsedHrTime = process.hrtime(startHrTime);
const elapsedTimeInMs = elapsedHrTime[0] * 1000 + elapsedHrTime[1] / 1e6;
console.log(`Response ${res.statusCode} ${elapsedTimeInMs.toFixed(3)} ms`);
const body = Buffer.concat(chunk).toString('utf-8');
console.log(`Response Body: ${body}`);
(oldEnd as Function).apply(res, arguments);
}
next();
}
// api/utils/server.ts
...
server.use(bodyParser.json());
server.use(morgan(`:method :url :status :response-time ms - :res[content-length]`));
morganBody(server);
server.use(expressDevLogger);
...
npm i dotenv-extended dotenv-parse-variables
npm i -D @types/dotenv-parse-variables
package.json
{
"start": "ENV_FILE=./config/.env.prod node -r tsconfig-paths/register ./bin/app.js ",
"dev": "ENV_FILE=./config/.env.dev ./node_modules/.bin/ts-node -r tsconfig-paths/register ./src/app.ts ",
}
server.ts
...
server.use(bodyParser.json());
if(config.morganLogger){
server.use(morgan(`:method :url :status :response-time ms - :res[content-length]`));
}
if(config.morganBodyLogger){
morganBody(server);
}
if(config.exmplDevLogger){
server.use(expressDevLogger);
}
const connect = connector(api, apiDefinition, {
...
npm i winston
src/utils/logger.ts
import winston from 'winston';
import config from '@exmpl/config';
const prettyJson = winston.format.printf( info => {
if(info.message.constructor === Object){
info.message = JSON.stringify(info.message, null, 4);
}
return `${info.timestamp} ${info.label || '-'} ${info.level}: ${info.message} `;
});
const logger = winston.createLogger({
level: config.loggerLevel === 'silent' ? undefined : config.loggerLevel,
silent: config.loggerLevel === 'silent',
format: winston.format.combine(
winston.format.colorize(),
winston.format.prettyPrint(),
winston.format.splat(),
winston.format.simple(),
winston.format.timestamp({format: 'YYYY-MM-DD HH:mm:ss.SSS'}),
prettyJson,
),
defaultMeta: { service: 'api-example' },
transports: [new winston.transports.Console({})],
});
export default logger;
config/.env.schema
...
# see src/utils/logger.ts for the list of values
LOGGER_LEVEL=
npm i -D jest @types/jest ts-jest
./node_modules/.bin/ts-jest config:init
cat jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'@exmpl/(.*)':'<rootDir>/src/$1'
},
};
package.json
{
"test:unit": "ENV_FILE=./config/.env.test ./node_modules/.bin/jest"
}
touch config/.env.test
src/api/services/tests/user.ts
import user from '../user';
describe('auth', () => {
it('should resolve to true and valid userId for hardcoded token', async () => {
const response = await user.auth('fakeToken');
expect(response).toEqual({userId: 'fakeTokenId'});
});
it('should resolve with false for invalid token', async () => {
const response = await user.auth('invalidToken');
expect(response).toEqual({error: {type: 'unauthorized', message: 'Authorization Failed'}});
});
});
npm run test:unit
npm i -D supertest @types/supertest
mkdir src/api/controllers/__tests__
touch src/api/controllers/__tests__/greeting.ts
src/api/controllers/tests/greeting.ts
import request from 'supertest';
import {Express} from 'express-serve-static-core';
import {createServer} from '@exmpl/utils/server';
import { doesNotMatch } from 'assert';
let server: Express;
beforeAll(async () => {
server = await createServer();
});
describe('GET /hello', () => {
it('should return 200 and valid response when param list is empty', async (done) => {
request(server)
.get(`/api/v1/hello`)
.expect('Content-Type', /json/)
.expect(200)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({'message':'Hello, stranger!'});
done();
})
});
it('should return 200 and valid response when name param is set', async (done) => {
const nameParam = 'MaximilianoTestName';
request(server)
.get(`/api/v1/hello?name=${nameParam}`)
.expect('Content-Type', /json/)
.expect(200)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({'message': `Hello, ${nameParam}!`});
done();
});
});
it.skip('should return 400 and valid error response when param is empty', async (done) => {
request(server)
.get(`/api/v1/hello?name=`)
.expect('Content-Type', /json/)
.expect(400)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({'error': {
type: 'request_validation',
message: expect.stringMatching(/Empty.*\'name\'/),
errors: expect.anything()
}});
});
});
});
npm run test:u src/api/controllers/__tests__/greeting.ts
src/api/controllers/tests/greeting.ts
import request from 'supertest';
import {Express} from 'express-serve-static-core';
import {createServer} from '@exmpl/utils/server';
let server: Express;
beforeAll(async () => {
server = await createServer();
});
describe('GET /hello', () => {
it('should return 200 and valid response when param list is empty', async (done) => {
request(server)
.get(`/api/v1/hello`)
.expect('Content-Type', /json/)
.expect(200)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({'message':'Hello, stranger!'});
done();
})
});
it('should return 200 and valid response when name param is set', async (done) => {
const nameParam = 'MaximilianoTestName';
request(server)
.get(`/api/v1/hello?name=${nameParam}`)
.expect('Content-Type', /json/)
.expect(200)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({'message': `Hello, ${nameParam}!`});
done();
});
});
it('should return 400 and valid error response when param is empty', async (done) => {
request(server)
.get(`/api/v1/hello?name=`)
.expect('Content-Type', /json/)
.expect(400)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({'error': {
type: 'request_validation',
message: expect.stringMatching(/Empty.*\'name\'/),
errors: expect.anything()
}});
done();
});
});
});
describe('GET /goodbye', () => {
it('should return 200 and valid response to authorization with fakeToken request', async (done) => {
request(server)
.get(`/api/v1/goodbye`)
.set('Authorization', 'Bearer fakeToken')
.expect('Content-Type', /json/)
.expect(200)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({'message': 'Goodbye, fakeTokenId!'});
done();
});
});
it('shourd return 401 and valid error response to invalid auth token', async (done) => {
request(server)
.get(`/api/v1/goodbye`)
.set('Authorization', 'Bearer invalidFakeToken')
.expect(401)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject(
{error: {type: 'unauthorized', message: 'Authorization Failed'}});
done();
});
});
it('should return 401 and valid error response if authorization header is missed', async (done) => {
request(server)
.get(`/api/v1/goodbye`)
.expect('Content-Type', /json/)
.expect(401)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({'error': {
type: 'request_validation',
message: 'Authorization header required',
errors: expect.anything()
}});
done();
});
});
});
package.json
{
"test:u": "ENV_FILE=./config/.env.test ./node_modules/.bin/jest --verbose --coverage"
}
https://losikov.medium.com/part-4-node-js-express-typescript-unit-tests-with-jest-5204414bf6f0
controllers/tests/user_failure.ts
import request from 'supertest';
import {Express} from 'express-serve-static-core';
import UserService from '@exmpl/api/services/user';
import {createServer} from '@exmpl/utils/server';
jest.mock('@exmpl/api/services/user');
let server: Express;
beforeAll( async () => {
server = await createServer();
});
describe('auth failure', () => {
it('sould return 500 and valid response if auth reject', async (done) => {
(UserService.auth as jest.Mock).mockRejectedValue(new Error());
request(server)
.get(`/api/v1/goodbye`)
.set('Authorization', 'Bearer fakeToken')
.expect(500)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({error: {
type: 'internal_server_error',
message: 'Internal Server Error'
}});
done();
});
});
});
...https://losikov.medium.com/part-5-mongodb-with-mongoose-d01144739002
npm i mongoose bcrypt mongodb-memory-server
npm i -D @types/mongoose @types/bcrypt @types/mongodb-memory-server
npm i -D faker @types/faker
maximilianou@instrument:~/projects/weekly22$ cat api/config/.env.schema
MORGAN_LOGGER=
MORGAN_BODY_LOGGER=
EXMPL_DEV_LOGGER=
# see src/utils/logger.ts for the list of values
LOGGER_LEVEL=
MONGO_URL=
MONGO_CREATE_INDEX=
MONGO_AUTO_INDEX=
maximilianou@instrument:~/projects/weekly22$ cat api/config/.env.dev
MORGAN_LOGGER=true
MORGAN_BODY_LOGGER=true
EXMPL_DEV_LOGGER=true
LOGGER_LEVEL=debug
MONGO_URL=mongodb://localhost/exmpl
MONGO_AUTO_INDEX=true
maximilianou@instrument:~/projects/weekly22$ cat api/config/.env.prod
MORGAN_LOGGER=true
LOGGER_LEVEL=http
MONGO_URL=mongodb://localhost/exmpl
MONGO_AUTO_INDEX=false
maximilianou@instrument:~/projects/weekly22$ cat api/config/.env.test
MONGO_URL=inmemory
MONGO_AUTO_INDEX=true
// src/config/index.ts
import dotenvExtended from 'dotenv-extended';
import dotenvParseVariables from 'dotenv-parse-variables';
type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'http' | 'verbose' | 'debug' | 'silly';
const env = dotenvExtended.load({
path: process.env.ENV_FILE,
defaults: './config/.env.defaults',
schema: './config/.env.schema',
includeProcessEnv: true,
silent: false,
errorOnMissing: true,
errorOnExtra: true
});
const parsedEnv = dotenvParseVariables(env);
interface Config {
morganLogger: boolean,
morganBodyLogger: boolean,
exmplDevLogger: boolean,
loggerLevel: LogLevel,
mongo: {
url: string,
useCreateIndex: boolean,
autoIndex: boolean,
},
};
const config : Config = {
morganLogger: parsedEnv.MORGAN_LOGGER as boolean,
morganBodyLogger: parsedEnv.MORGAN_BODY_LOGGER as boolean,
exmplDevLogger: parsedEnv.EXMPL_DEV_LOGGER as boolean,
loggerLevel: parsedEnv.LOGGER_LEVEL as LogLevel,
mongo: {
url: parsedEnv.MONGO_URL as string,
useCreateIndex: parsedEnv.MONGO_CREATE_INDEX as boolean,
autoIndex: parsedEnv.MONGO_AUTO_INDEX as boolean,
},
};
export default config;
// src/utils/db.ts
/* istanbul ignore file */
import mongoose from 'mongoose';
import {MongoMemoryServer} from 'mongodb-memory-server';
import config from '@exmpl/config';
import logger from '@exmpl/utils/logger';
mongoose.Promise = global.Promise;
mongoose.set('debug', process.env.DEBUG !== undefined);
const opts = {
useNewUrlParser: true,
useUnifiedTopology: true,
useCreateIndex: config.mongo.useCreateIndex,
keepAlive: true,
keepAliveInitialDelay: 300000,
autoIndex: config.mongo.autoIndex,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
};
class MongoConnection {
private static _instance: MongoConnection;
private _mongoServer?: MongoMemoryServer;
static getInstance(): MongoConnection {
if(!MongoConnection._instance){
MongoConnection._instance = new MongoConnection();
}
return MongoConnection._instance;
};
public async open(): Promise<void> {
try{
if(config.mongo.url === 'inmemory'){
logger.debug('connecting to inmemory mongodb');
this._mongoServer = new MongoMemoryServer();
const mongoUrl = await this._mongoServer.getConnectionString();
await mongoose.connect(mongoUrl, opts);
}else{
logger.debut(`connecting to mongodb: ${config.mongo.url}`);
mongoose.connect(config.mongo.url, opts);
}
mongoose.connection.on('connected', () => {
logger.info('Mongo: connected.);
});
mongoose.connection('disconnected', () => {
logger.info('Mongo: disconnected.);
});
mongoose.connection.on('error', (err) => {
logger.error(`Mongo: ${String(err)}`);
if(err.name === "MongoNetworkError"){
setTimeout( () => {
mongoose.connect(config.mongo.url, opts).catch(() => {});
}, 5000);
}
});
}catch(err){
logger.error(`db.open: ${err}`);
throw err;
}
}
public async close(): Promise<void> {
try{
await mongoose.disconnect();
if(config.mongo.url === 'inmemory'){
await this._mongoServer!.stop();
}
}catch(err){
logger.error(`db.open: ${err}`);
throw err;
}
}
};
export default MongoConnection.getInstance();
// src/app.ts
import logger from '@exmpl/utils/logger';
import {createServer} from '@exmpl/utils/server';
import db from '@exmpl/utils/db';
db.open()
.then( () =>
createServer() )
.then( (server: { listen: (arg0: number, arg1: () => void) => void; }) => {
server.listen( 3021, () => {
logger.info(`Listening on port: ${3021}`);
})
})
.catch( (err: any) => {
logger.error(`Error:: ${err}`);
});
models/user.ts
import bcrypt from 'bcrypt';
import {Schema, Document, model, Model} from 'mongoose';
import validator from 'validator';
interface IUserDocument extends Document {
password: string,
email: string,
name: string,
created: Date,
}
export interface IUser extends IUserDocument {
comparePassword(password: string): Promise<boolean> ;
}
const userSchema = new Schema<IUser>({
password: {type: String, required: true},
email: {type: String, required: true, trim: true,
validate: [validator.isEmail, 'do not match email regex']},
name: {type: String, required: true},
created: {type:Date, default: Date.now},
}, {strict: true}).index({email:1},
{unique: true,
collation: {locale: 'en_US', strength: 1}, sparse: true});
userSchema.pre<IUserDocument>('save', function(next):void {
if(this.isModified('password')){
bcrypt.genSalt(10, (err, salt) => {
if(err) return next(err);
bcrypt.hash(this.password, salt, (err, hash) => {
if(err) return next(err);
this.password = hash;
next();
});
});
}else{
next();
}
});
userSchema.set('toJSON', {
transform: function(doc:any, ret:any, options:any){
ret.created = ret.created.getTime();
delete ret.__v;
delete ret._id;
delete ret.password;
}
});
userSchema.methods.comparePassword = function(candidatePassword: string): Promise<boolean>{
const {password} = this;
return new Promise(function(resolve, reject){
bcrypt.compare(candidatePassword, password, function(err, isMatch){
if(err) return reject(err);
return resolve(isMatch);
});
});
};
export interface IUserModel extends Model<IUser>{
}
export const User: IUserModel = model<IUser, IUserModel>('User', userSchema);
export default User;
models/tests/user.ts
import faker, { fake } from 'faker';
import User from '@exmpl/api/models/user';
import db from '@exmpl/utils/db';
beforeAll(async () => {
await db.open();
});
afterAll(async () => {
await db.close();
});
describe('save', () => {
it('should create user', async () => {
const email = faker.internet.email();
const password = faker.internet.password();
const name = faker.name.firstName();
const before = Date.now();
const user = new User({
email: email, password: password, name: name
});
await user.save();
const after = Date.now();
const fetched = await User.findById(user._id);
expect(fetched).not.toBeNull();
expect(fetched!.email).toBe(email);
expect(fetched!.name).toBe(name);
expect(fetched!.password).not.toBe(password);
expect(before).toBeLessThanOrEqual(fetched!.created.getTime());
});
it('should update user', async () => {
const name1 = faker.name.firstName();
const user = new User({
email: faker.internet.email(),
password: faker.internet.password(),
name: name1,
});
const dbUser1 = await user.save();
const name2 = faker.name.firstName();
dbUser1.name = name2;
const dbUser2 = await dbUser1.save();
expect(dbUser2.name).toEqual(name2);
});
it('should not save user with invalid mail', async () => {
const user1 = new User({
name: faker.name.findName(),
email: 'e@e',
password: faker.internet.password(),
});
return expect(user1.save()).rejects.toThrowError(/email/);
});
it('should not save user without an email', async () => {
const user = new User({
password: faker.internet.password(),
name: faker.name.firstName(),
});
return expect(user.save()).rejects.toThrowError(/email/);
});
it('should not save without a password', async () => {
const user2 = new User({
email: faker.internet.email(),
name: faker.name.firstName,
});
return expect(user2.save()).rejects.toThrowError(/password/);
});
it('should not save user without a name', async () => {
const user1 = new User({
email: faker.internet.email(),
password: faker.internet.password(),
});
return expect(user1.save()).rejects.toThrowError(/name/);
});
it('should not save users with the same email', async () => {
const email = faker.internet.email();
const password = faker.internet.password();
const name = faker.name.firstName();
const userData = { email: email, password: password, name: name};
const user1 = new User(userData);
await user1.save();
const user2 = new User(userData);
return expect(user2.save()).rejects.toThrowError(/E11000/);
});
it('should not save password in a readable form', async () => {
const password = faker.internet.password();
const user1 = new User({
email: faker.internet.email(),
password: password,
name: faker.name.firstName(),
});
await user1.save();
expect(user1.password).not.toBe(password);
const user2 = new User({
email: faker.internet.email(),
password: password,
name: faker.name.firstName(),
});
await user2.save();
expect(user2.password).not.toBe(password);
expect(user1.password).not.toBe(user2.password);
});
});
describe('comparePassword', () => {
it('should return true for valid password', async () => {
const password = faker.internet.password();
const user = new User({
email: faker.internet.email(),
password: password,
name: faker.name.firstName(),
});
await user.save();
expect(await user.comparePassword(password)).toBe(true);
});
it('should return false for invalid password', async () => {
const user = new User({
email: faker.internet.email(),
password: faker.internet.password,
name: faker.name.firstName(),
});
await user.save();
expect(await user.comparePassword(faker.internet.password())).toBe(false);
});
it('should update password hash if password is updated', async () => {
const password1 = faker.internet.password();
const user = new User({
email: faker.internet.email(),
password: password1,
name: faker.name.findName(),
});
const dbUser1 = await user.save();
expect(await dbUser1.comparePassword(password1)).toBe(true);
const password2 = faker.internet.password();
dbUser1.password = password2;
const dbUser2 = await dbUser1.save();
expect(await dbUser2.comparePassword(password2)).toBe(true);
expect(await dbUser2.comparePassword(password1)).toBe(false);
});
});
describe('toJSON', () => {
it('should return valid JSON', async () => {
const email = faker.internet.email();
const password = faker.internet.password();
const name = faker.name.findName();
const user = new User({email: email, password: password, name: name});
await user.save();
expect(user.toJSON()).toEqual({ email: email, name: name, created: expect.any(Number)});
});
});
Here we have version conflicts.. over the time..
FAIL src/api/models/__tests__/user.ts
â—Ź Test suite failed to run
Error: Status Code is 403 (MongoDB's 404)
This means that the requested version-platform combination doesn't exist
Used Url: "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-debian10-4.0.14.tgz"
Try to use different version 'new MongoMemoryServer({ binary: { version: 'X.Y.Z' } })'
package.json
{
"config": {
"mongodbMemoryServer": {
"version": "4.4.1"
}
},
}
Reinstall packages
/api$ rm package-lock.json
/api$ rm -rf node_modules/
/api$ npm i
api$ npm run test:u
> api@1.0.0 test:u /home/maximilianou/projects/weekly22/api
> ENV_FILE=./config/.env.test ./node_modules/.bin/jest --verbose --coverage --detectOpenHandles
PASS src/api/models/__tests__/user.ts
save
âś“ should create user (111 ms)
âś“ should update user (78 ms)
âś“ should not save user with invalid mail (5 ms)
âś“ should not save user without an email (3 ms)
âś“ should not save without a password (3 ms)
âś“ should not save user without a name (3 ms)
âś“ should not save users with the same email (138 ms)
âś“ should not save password in a readable form (131 ms)
comparePassword
âś“ should return true for valid password (126 ms)
âś“ should return false for invalid password (126 ms)
âś“ should update password hash if password is updated (312 ms)
toJSON
âś“ should return valid JSON (66 ms)
PASS src/api/controllers/__tests__/user_failure.ts
auth failure
âś“ sould return 500 and valid response if auth reject (187 ms)
PASS src/api/controllers/__tests__/greeting.ts
GET /hello
âś“ should return 200 and valid response when param list is empty (109 ms)
âś“ should return 200 and valid response when name param is set (17 ms)
âś“ should return 400 and valid error response when param is empty (22 ms)
GET /goodbye
âś“ should return 200 and valid response to authorization with fakeToken request (12 ms)
âś“ shourd return 401 and valid error response to invalid auth token (10 ms)
âś“ should return 401 and valid error response if authorization header is missed (8 ms)
PASS src/api/services/__tests__/user.ts
auth
âś“ should resolve to true and valid userId for hardcoded token (2 ms)
âś“ should resolve with false for invalid token (1 ms)
-----------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files | 96.15 | 75 | 100 | 100 |
api/controllers | 100 | 100 | 100 | 100 |
greeting.ts | 100 | 100 | 100 | 100 |
index.ts | 100 | 100 | 100 | 100 |
user.ts | 100 | 100 | 100 | 100 |
api/models | 89.66 | 62.5 | 100 | 100 |
user.ts | 89.66 | 62.5 | 100 | 100 | 29-55
api/services | 100 | 100 | 100 | 100 |
user.ts | 100 | 100 | 100 | 100 |
config | 100 | 100 | 100 | 100 |
index.ts | 100 | 100 | 100 | 100 |
utils | 100 | 50 | 100 | 100 |
express.ts | 100 | 50 | 100 | 100 | 10
-----------------|---------|----------|---------|---------|-------------------
Test Suites: 4 passed, 4 total
Tests: 21 passed, 21 total
Snapshots: 0 total
Time: 4.593 s, estimated 6 s
Ran all test suites.
npm run build
npm run start
http://localhost:3022/api-docs/
import faker from 'faker';
import request from 'supertest';
import {Express} from 'express-serve-static-core';
import db from '@exmpl/utils/db';
import {createServer} from '@exmpl/utils/server';
let server: Express;
beforeAll(async () => {
await db.open();
server = await createServer();
});
afterAll(async () => {
await db.close();
// server kill signal
});
describe('POST /api/v1/users', () => {
it('should return 201 and valid response for valid user', async (done) => {
request(server)
.post(`/api/v1/users`)
.send({
email: faker.internet.email(),
password: faker.internet.password(),
name: faker.name.firstName(),
})
.expect(201)
.end( function(err, res){
if(err) return done(err);
expect(res.body).toMatchObject({
userId: expect.stringMatching(/^[a-z0-9]{24}/)
});
done();
});
});
it('should return 409 and valid response for duplicate', async (done) => {
const data = {
email: faker.internet.email(),
password: faker.internet.password(),
name: faker.name.firstName()
};
request(server)
.post(`/api/v1/users`)
.send(data)
.expect(201)
.end(function(err, res){
if(err) return done(err);
request(server)
.post(`/api/v1/users`)
.send(data)
.expect(409)
.end(function(err, res){
if(err) return done(err);
expect(res.body).toMatchObject({
error: {
type: 'account_already_exists',
message: expect.stringMatching(/already exists/)
}
})
done();
});
});
});
it('should return 400 and valid response for invalid request', async (done) => {
request(server)
.post(`/api/v1/users`)
.send({
mail: faker.internet.email(),
password: faker.internet.password(),
name: faker.name.firstName(),
})
.expect(400)
.end(function(err, res){
if(err) return done(err);
expect(res.body).toMatchObject({
error: {
type: 'request_validation',
message: expect.stringMatching(/email/),
}
})
done();
});
});
});
controllers/tests/user.ts
import * as express from 'express';
import UserService, {ErrorResponse} from '@exmpl/api/services/user';
import {writeJsonResponse} from '@exmpl/utils/express';
import logger from '@exmpl/utils/logger';
export const auth = (req: express.Request, res: express.Response,
next: express.NextFunction): void => {
logger.debug(`controller::user.ts::auth()`);
const token = req.headers.authorization!;
logger.debug(`controller::user.ts::auth() .. token=[${token}]`);
UserService.auth(token)
.then(authResponse => {
logger.debug(`controller::user.ts::auth() .. authResponse=[${authResponse}]`);
if(!(authResponse as any).error){
res.locals.auth = {
userId: (authResponse as {userId: string}).userId
};
next();
}else{
writeJsonResponse(res, 401, authResponse);
}
})
.catch(err => {
logger.error(`auth: ${err}`);
writeJsonResponse(res, 500, {
error: {
type: 'internal_server_error',
message: 'Internal Server Error'
}});
});
}
export const createUser = (req: express.Request, res: express.Response): void => {
const {email, password, name} = req.body;
UserService.createUser(email, password, name)
.then( resp => {
if((resp as any).error){
if( (resp as ErrorResponse).error.type === 'account_already_exists'){
writeJsonResponse(res, 409, resp);
}else{
throw new Error(`unsupported ${resp}`);
}
}else{
writeJsonResponse(res, 201, resp);
}
})
.catch( (err: any) => {
logger.error(`createUser: ${err}`);
writeJsonResponse(res, 500, {
error: {
type: 'internal_server_error',
message: 'Internal Server Error'} });
});
}
services/tests/user.ts
import faker from 'faker';
import db from '@exmpl/utils/db';
import user from '../user';
beforeAll(async () => {
await db.open();
});
afterAll( async () => {
await db.close();
});
describe('auth', () => {
it('should resolve to true and valid userId for hardcoded token', async () => {
const response = await user.auth('fakeToken');
expect(response).toEqual({userId: 'fakeTokenId'});
});
it('should resolve with false for invalid token', async () => {
const response = await user.auth('invalidToken');
expect(response).toEqual({error: {type: 'unauthorized', message: 'Authorization Failed'}});
});
});
describe('createUser', () => {
it('should resolve true in valid userId', async () => {
const email = faker.internet.email();
const password = faker.internet.password();
const name = faker.name.findName();
await expect( user.createUser(email, password, name)).resolves.toEqual({
userId: expect.stringMatching(/^[a-f0-9]{24}$/)
});
});
it('should resolves false and valid error if duplicate', async () => {
const email = faker.internet.email();
const password = faker.internet.password();
const name = faker.name.findName();
await user.createUser(email, password, name);
await expect(user.createUser(email, password, name)).resolves.toEqual({
error: {
type: 'account_already_exists',
message: `${email} already exists`,
}
});
});
});
services/auth.ts
export type ErrorResponse = { error: {type: string, message: string}};
export type AuthResponse = ErrorResponse | {userId: string};
export type CreateUserResponse = ErrorResponse | {userId: string};
import logger from '@exmpl/utils/logger';
import User from '@exmpl/api/models/user';
function auth(bearerToken: string): Promise<AuthResponse>{
logger.debug(`services::user.ts::auth()`);
return new Promise(function(resolve, reject){
const token = bearerToken.replace('Bearer ','');
logger.debug(`services::user.ts::auth() .. token::[${token}]`);
if(token === 'fakeToken'){
return resolve({userId: 'fakeTokenId'});
}
return resolve({error: {type: 'unauthorized', message: 'Authorization Failed'}});
});
};
function createUser(email: string, password: string, name: string): Promise<CreateUserResponse> {
return new Promise( (resolve, reject) => {
const user = new User({email: email, password:password, name:name});
user.save()
.then( u => {
resolve({ userId: u._id.toString() });
})
.catch( err => {
if( err.code === 11000 ){
resolve( {error: { type: 'account_already_exists', message: `${email} already exists`}} );
}else{
logger.error(`createUser: ${err}`);
reject(err);
}
});
});
};
export default { auth: auth, createUser: createUser };
npm run test:u
controllers/tests/user_failure.ts
import request from 'supertest';
import {Express} from 'express-serve-static-core';
import {createServer} from '@exmpl/utils/server';
import { mocked } from 'ts-jest/utils';
import UserService, { AuthResponse } from '@exmpl/api/services/user';
let server: Express;
beforeAll( async () => {
server = await createServer();
});
describe('auth failure', () => {
it('sould return 500 and valid response if auth reject', async (done) => {
const MockedUserService = mocked(UserService, true);
MockedUserService.auth = jest.fn().mockRejectedValue(new Error());
request(server)
.get(`/api/v1/goodbye`)
.set('Authorization', 'Bearer fakeToken')
.expect(500)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({error: {
type: 'internal_server_error',
message: 'Internal Server Error'
}});
done();
});
});
});
import * as express from 'express';
import UserService, {ErrorResponse} from '@exmpl/api/services/user';
import {writeJsonResponse} from '@exmpl/utils/express';
import logger from '@exmpl/utils/logger';
export const auth = (req: express.Request, res: express.Response,
next: express.NextFunction): void => {
logger.debug(`controller::user.ts::auth()`);
const token = req.headers.authorization!;
logger.debug(`controller::user.ts::auth() .. token=[${token}]`);
UserService.auth(token)
.then(authResponse => {
logger.debug(`controller::user.ts::auth() .. authResponse=[${authResponse}]`);
if(!(authResponse as any).error){
res.locals.auth = {
userId: (authResponse as {userId: string}).userId
};
next();
}else{
writeJsonResponse(res, 401, authResponse);
}
})
.catch(err => {
logger.error(`auth: ${err}`);
writeJsonResponse(res, 500, {
error: {
type: 'internal_server_error',
message: 'Internal Server Error'
}});
});
}
export const createUser = (req: express.Request, res: express.Response): void => {
const {email, password, name} = req.body;
UserService.createUser(email, password, name)
.then( resp => {
if((resp as any).error){
if( (resp as ErrorResponse).error.type === 'account_already_exists'){
writeJsonResponse(res, 409, resp);
}else{
throw new Error(`unsupported ${resp}`);
}
}else{
writeJsonResponse(res, 201, resp);
}
})
.catch( (err: any) => {
logger.error(`createUser: ${err}`);
writeJsonResponse(res, 500, {
error: {
type: 'internal_server_error',
message: 'Internal Server Error'} });
});
}
utils/server.ts
import express from 'express';
import { Express } from 'express-serve-static-core'
import * as OpenApiValidator from 'express-openapi-validator';
import { connector, summarise } from 'swagger-routes-express';
import YAML from 'yamljs';
import swaggerUi from 'swagger-ui-express';
import * as api from '@exmpl/api/controllers';
//import bodyParser from "body-parser"; // REFERENCE:: https://stackoverflow.com/questions/10005939/how-do-i-consume-the-json-post-data-in-an-express-application
import morgan from 'morgan';
import morganBody from 'morgan-body';
import {expressDevLogger} from '@exmpl/utils/express_dev_logger';
import config from '@exmpl/config';
import logger from '@exmpl/utils/logger';
export const createServer = async (): Promise<Express> => {
logger.debug(`utils::server.ts::createServer()`);
const yamlSpecFile = './config/openapi.yml';
const apiDefinition = YAML.load(yamlSpecFile);
const apiSummary = summarise(apiDefinition);
const server = express();
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDefinition));
const validatorOptions = {
apiSpec: yamlSpecFile,
validateRequests: true,
validateResponses: true
}
//server.use(bodyParser.json()); // REFERENCE:: https://stackoverflow.com/questions/10005939/how-do-i-consume-the-json-post-data-in-an-express-application
server.use(express.json()); // REFERENCE:: https://stackoverflow.com/questions/10005939/how-do-i-consume-the-json-post-data-in-an-express-application
server.use(OpenApiValidator.middleware(validatorOptions));
logger.info(apiSummary);
server.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
res.status(err.status).json({
error: {
type: 'request_validation',
message: err.message,
errors: err.errors
}
});
});
/* istanbul ignore file */
if(config.morganLogger){
server.use(morgan(`:method :url :status :response-time ms - :res[content-length]`));
}
/* istanbul ignore file */
if(config.morganBodyLogger){
morganBody(server);
}
/* istanbul ignore file */
if(config.exmplDevLogger){
server.use(expressDevLogger);
}
const connect = connector(api, apiDefinition, {
onCreateRoute: (method: string, descriptor: any[]) => {
descriptor.shift();
logger.verbose(`${method}: ${descriptor.map((d:any) => d.name).join(', ')}`);
},
security: {
bearerAuth: api.auth
}
});
connect(server);
return server;
}
test/user_failure.ts
import request from 'supertest';
import {Express} from 'express-serve-static-core';
import {createServer} from '@exmpl/utils/server';
import { mocked } from 'ts-jest/utils';
import UserService, { AuthResponse } from '@exmpl/api/services/user';
import faker from 'faker';
let server: Express;
beforeAll( async () => {
server = await createServer();
});
describe('auth failure', () => {
it('sould return 500 and valid response if auth reject', async (done) => {
const MockedUserService = mocked(UserService, true);
MockedUserService.auth = jest.fn().mockRejectedValue(new Error());
request(server)
.get(`/api/v1/goodbye`)
.set('Authorization', 'Bearer fakeToken')
.expect(500)
.end((err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({error: {
type: 'internal_server_error',
message: 'Internal Server Error'
}});
done();
});
});
});
describe('createUser failure', () =>{
it('should return 500 and valid response if auth rejects with an error', async (done) => {
const MockedUserService = mocked(UserService, true);
MockedUserService.createUser = jest.fn().mockResolvedValue({error: {type: 'unknown'}});
request(server)
.post(`/api/v1/users`)
.send({
email: faker.internet.email(),
password: faker.internet.password(),
name: faker.name.findName()
})
.expect(500)
.end( (err, res) => {
if(err) return done(err);
expect(res.body).toMatchObject({error: {
type: 'internal_server_error',
message: 'Internal Server Error'}});
done();
});
});
});
Reference:
- Auth
https://www.npmjs.com/package/swagger-routes-express
https://swagger.io/docs/specification/authentication/
https://swagger.io/docs/specification/2-0/authentication/
https://cevo.com.au/post/docker-cli-integration-with-amazon-ecs/
https://github.com/piotrwitek/react-redux-typescript-guide
https://dev.to/busypeoples/notes-on-typescript-pick-exclude-and-higher-order-components-40cp
https://www.freecodecamp.org/news/a-mental-model-to-think-in-typescript-2/amp/
-
Medical English Vocabulary - https://www.englishclub.com/english-for-work/medical-vocabulary.htm
-
Web HTML - https://www.w3.org/TR/