├── tests ├── utils.ts ├── test_photo1.jpg ├── test_photo2.jpg └── profiles.test.ts ├── .husky ├── .gitignore └── pre-commit ├── .gitignore ├── types ├── dbtypes │ ├── baseType.ts │ ├── softDeletable.ts │ ├── user.ts │ └── profile.ts ├── validation.ts └── apitypes │ └── profile.ts ├── .prettierrc.js ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── new-feature.md ├── exceptions ├── HTTPError.ts └── utils.ts ├── .eslintrc.js ├── services └── database.service.ts ├── controllers ├── challenge.ts └── profile.ts ├── jest.config.js ├── server.ts ├── dbmodels ├── user.ts └── profile.ts ├── app.ts ├── repositories ├── user.ts └── profile.ts ├── README.md ├── routes ├── auth.ts └── profile.ts ├── package.json ├── authentication.ts └── tsconfig.json /tests/utils.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | .env -------------------------------------------------------------------------------- /tests/test_photo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/huskyhabits-backend/main/tests/test_photo1.jpg -------------------------------------------------------------------------------- /tests/test_photo2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/huskyhabits-backend/main/tests/test_photo2.jpg -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | npx lint-staged 6 | -------------------------------------------------------------------------------- /types/dbtypes/baseType.ts: -------------------------------------------------------------------------------- 1 | // Base type for all Schema that includes schema versioning 2 | export interface IBaseType { 3 | schema_version: number; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | jsxBracketSameLine: true, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | // Override any other rules you want 7 | }; 8 | -------------------------------------------------------------------------------- /types/validation.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | import { POSTCreateProfileSchema } from './apitypes/profile'; 3 | export const ajv = new Ajv(); 4 | 5 | ajv.addSchema(POSTCreateProfileSchema, 'POSTCreateProfile'); 6 | -------------------------------------------------------------------------------- /types/dbtypes/softDeletable.ts: -------------------------------------------------------------------------------- 1 | import { IBaseType } from './baseType'; 2 | 3 | // Base type for some schema that will be soft-deletable for data recovery 4 | export interface ISoftDeletable extends IBaseType { 5 | deleted: boolean; 6 | date_deleted: Date; 7 | } 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Expected Behavior 11 | 12 | ## Current Behavior 13 | 14 | ## Steps to reproduce 15 | 16 | 1. 17 | -------------------------------------------------------------------------------- /exceptions/HTTPError.ts: -------------------------------------------------------------------------------- 1 | // Represents an error with an HTTP status code thrown by an api controller 2 | export default class HTTPError { 3 | msg: string; 4 | code: number; 5 | 6 | constructor(msg: string, code: number) { 7 | this.msg = msg; 8 | this.code = code; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New feature 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | ## Spec 13 | 14 | - [ ] 15 | - [ ] 16 | - [ ] 17 | - [ ] 18 | 19 | **Contact @ if you need help** 20 | -------------------------------------------------------------------------------- /types/dbtypes/user.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | import { ISoftDeletable } from './softDeletable'; 3 | 4 | // Type representing a user object in the db 5 | export interface IUser extends ISoftDeletable { 6 | _id: Schema.Types.ObjectId; 7 | email: string; 8 | first_name: string; 9 | last_name: string; 10 | accounts: [{ acc_type: string; uid: string }]; 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | 'airbnb', 8 | ], 9 | parser: '@typescript-eslint/parser', 10 | parserOptions: { 11 | ecmaFeatures: { 12 | jsx: true, 13 | }, 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | }, 17 | plugins: [ 18 | '@typescript-eslint', 19 | ], 20 | rules: { 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /services/database.service.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import 'dotenv/config'; 3 | 4 | export const connectDatabase = async () => { 5 | const mongoDB: string = process.env.DATABASE || ''; 6 | 7 | try { 8 | // connect to MongoDB 9 | await mongoose.connect(mongoDB); 10 | } catch (err: any) { 11 | console.error(err.message); 12 | // Exit process with failure 13 | process.exit(1); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /controllers/challenge.ts: -------------------------------------------------------------------------------- 1 | import { query } from 'express'; 2 | import { Controller, Delete, Get, Path, Post, Route, Query } from 'tsoa'; 3 | import { GETChallenge } from '../types/apitypes/challenge'; 4 | 5 | 6 | @Route("challenges") 7 | export class ChallengesController extends Controller { 8 | @Get("") 9 | public async getChallenges( 10 | @Query() name?: string 11 | ): Promise { 12 | return undefined; 13 | } 14 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: [ 3 | '/src/**/*.{js,jsx}', 4 | '!**/node_modules/**', 5 | '!**/vendor/**', 6 | ], 7 | moduleFileExtensions: ['ts', 'js', 'json'], 8 | transform: { 9 | '^.+\\.js$': 'babel-jest', 10 | '^.+\\.ts$': 'ts-jest', 11 | }, 12 | transformIgnorePatterns: [ 13 | '/node_modules/(?!vuex-class-modules).+\\.js$', 14 | ], 15 | moduleNameMapper: { 16 | '^@/(.*)$': '/src/$1', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import 'dotenv/config'; 3 | import { connectDatabase } from './services/database.service'; 4 | import app from './app'; 5 | 6 | const db = mongoose.connection; 7 | 8 | const port: number | string = process.env.PORT || 3000; 9 | 10 | connectDatabase(); 11 | 12 | db.on('error', console.error.bind(console, 'connection error: ')); 13 | 14 | db.once('open', (): void => { 15 | console.log('Connected successfully'); 16 | }); 17 | 18 | app.listen(port, (): void => console.log('Listening on Port 3000')); 19 | -------------------------------------------------------------------------------- /types/dbtypes/profile.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | import { ISoftDeletable } from './softDeletable'; 3 | 4 | // Type corresponding to a profile photo in the db 5 | export type ProfilePhoto = { 6 | data: Buffer; 7 | contentType: String; 8 | }; 9 | 10 | // Type representing a profile object in the db 11 | interface IProfile extends ISoftDeletable { 12 | _id: Schema.Types.ObjectId; 13 | user_id: Schema.Types.ObjectId; 14 | username: string; 15 | bio: string; 16 | photo: ProfilePhoto; 17 | } 18 | 19 | export default IProfile; 20 | -------------------------------------------------------------------------------- /dbmodels/user.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema } from 'mongoose'; 2 | import { IUser } from '../types/dbtypes/user'; 3 | 4 | // A logged-in User to Husky Habits 5 | const userSchema: Schema = new Schema( 6 | { 7 | email: { type: String, required: true, unique: true }, 8 | first_name: String, 9 | last_name: String, 10 | accounts: [{ acc_type: String, uid: String }], 11 | deleted: { type: Boolean, default: false }, 12 | date_deleted: Date, 13 | schema_version: { type: Number, default: 1 }, 14 | }, 15 | { timestamps: true }, 16 | ); 17 | 18 | export default model('User', userSchema); 19 | -------------------------------------------------------------------------------- /dbmodels/profile.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema } from 'mongoose'; 2 | import IProfile from '../types/dbtypes/profile'; 3 | 4 | // A Profile for a User 5 | const profileSchema: Schema = new Schema( 6 | { 7 | user_id: { type: Schema.Types.ObjectId, required: true }, 8 | username: { type: String, unique: true }, 9 | bio: String, 10 | photo: { data: Buffer, contentType: String }, 11 | deleted: { type: Boolean, default: false }, 12 | date_deleted: Date, 13 | schema_version: { type: Number, default: 1 }, 14 | }, 15 | { timestamps: true }, 16 | ); 17 | 18 | export default model('Profile', profileSchema); 19 | -------------------------------------------------------------------------------- /exceptions/utils.ts: -------------------------------------------------------------------------------- 1 | import HTTPError from './HTTPError'; 2 | import { Response } from 'express'; 3 | import { AnyValidateFunction } from 'ajv/dist/types'; 4 | 5 | // Responds to user with appropriate status code and message given an error from a controller 6 | export const sendError = (err: any, res: Response) => { 7 | if (err instanceof HTTPError) { 8 | res.status(err.code).send(err.msg); 9 | } else { 10 | res.status(500).send(err); 11 | } 12 | }; 13 | 14 | // Sends an error to the user due to a failed data validation 15 | export const sendValidationError = ( 16 | validate: AnyValidateFunction, 17 | res: Response, 18 | ) => { 19 | if (!validate.errors) { 20 | res.sendStatus(400); 21 | return; 22 | } 23 | 24 | res.status(400).send(`Input parsing error: ${validate.errors[0].message}`); 25 | }; 26 | -------------------------------------------------------------------------------- /types/apitypes/profile.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose'; 2 | import { JSONSchemaType } from 'ajv'; 3 | 4 | // Type returned when making a GET request to profile api 5 | export interface GETProfile { 6 | _id: Schema.Types.ObjectId; 7 | user_id: Schema.Types.ObjectId; 8 | username: string; 9 | bio: string; 10 | } 11 | 12 | // Type given to profile api in POST requests 13 | export interface POSTCreateProfile { 14 | username: string; 15 | bio?: string; 16 | } 17 | 18 | // AJV schema to validate POSTCreateProfile objects 19 | export const POSTCreateProfileSchema: JSONSchemaType = { 20 | type: 'object', 21 | properties: { 22 | username: { type: 'string' }, 23 | bio: { type: 'string', nullable: true }, 24 | }, 25 | required: ['username'], 26 | additionalProperties: false, 27 | }; 28 | -------------------------------------------------------------------------------- /app.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, Application } from 'express'; 2 | import cors from 'cors'; 3 | import 'dotenv/config'; 4 | import profiles from './routes/profile'; 5 | import authRoutes from './routes/auth'; 6 | import { setupAuthentication } from './authentication'; 7 | import fileUpload from 'express-fileupload'; 8 | 9 | const app: Application = express(); 10 | 11 | // Express config 12 | app.use(cors()); 13 | app.use(express.json()); 14 | 15 | // set up authentication middleware 16 | setupAuthentication(app); 17 | 18 | app.use(fileUpload()); 19 | 20 | /* ROUTES */ 21 | 22 | // base route 23 | app.get('/', (req: Request, res: Response): void => { 24 | res.send({ greeting: 'Hello world!' }); 25 | }); 26 | 27 | // add routes for profile API 28 | app.use('/api/v1/profiles', profiles); 29 | 30 | // add authentication routes (e.g. via Google, etc.) 31 | app.use('/auth', authRoutes); 32 | 33 | export default app; 34 | -------------------------------------------------------------------------------- /repositories/user.ts: -------------------------------------------------------------------------------- 1 | import UserSchema from '../dbmodels/user'; 2 | import HTTPError from '../exceptions/HTTPError'; 3 | import { IUser } from '../types/dbtypes/user'; 4 | import { Schema } from 'mongoose'; 5 | 6 | export const get_or_create_user = async ( 7 | email: string, 8 | first_name: string, 9 | last_name: string, 10 | ): Promise => { 11 | return UserSchema.findOne({ email: email }).then((result: IUser | null) => { 12 | if (!result) { 13 | return UserSchema.create({ 14 | email: email, 15 | first_name: first_name, 16 | last_name: last_name, 17 | }); 18 | } 19 | 20 | return result; 21 | }); 22 | }; 23 | 24 | export const get_user_by_id = async ( 25 | id: Schema.Types.ObjectId, 26 | ): Promise => { 27 | return UserSchema.findById(id).then((result: IUser | null | undefined) => { 28 | if (!result) { 29 | return Promise.reject(new HTTPError('User does not exist.', 40)); 30 | } 31 | 32 | return result; 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /repositories/profile.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document } from 'mongoose'; 2 | import ProfileModel from '../dbmodels/profile'; 3 | import HTTPError from '../exceptions/HTTPError'; 4 | import IProfile from '../types/dbtypes/profile'; 5 | 6 | // Gets a profile by id 7 | export const get_profile_by_id = async (id: string): Promise => { 8 | const profile = await ProfileModel.findById(id); 9 | if (!profile) return Promise.reject(new HTTPError('Profile not found', 404)); 10 | return profile; 11 | }; 12 | 13 | // Gets all profile with the given user id 14 | export const get_profiles_by_user_id = async ( 15 | user_id: Schema.Types.ObjectId, 16 | ): Promise => { 17 | return await ProfileModel.find({ user_id: user_id }); 18 | }; 19 | 20 | // Creates a new profile in the database 21 | export const create_profile = ( 22 | username: string, 23 | bio: string | undefined, 24 | user_id: Schema.Types.ObjectId, 25 | ): Promise => { 26 | return ProfileModel.create({ 27 | username: username, 28 | bio: bio, 29 | user_id: user_id, 30 | }); 31 | }; 32 | 33 | // Saves the given profile to the database 34 | export const save_profile = ( 35 | profile: Document & IProfile, 36 | ): Promise => { 37 | return profile.save(); 38 | }; 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Husky Habits Backend 2 | 3 | 4 | ## Setup 5 | 6 | Download [Yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable), a JavaScript package manager. 7 | 8 | Install dependencies: `yarn install` 9 | 10 | ### MongoDB 11 | 12 | MongoDB is a [NoSQL](https://www.mongodb.com/nosql-explained) database that features high availability and flexible data modeling. 13 | 14 | [Download MongoDB Community Server 5.0.5](https://docs.mongodb.com/manual/administration/install-community/) 15 | 16 | *For MacOS, I recommend downloading with Homebrew with commands below. 17 | 18 | **Install Brew (if you don’t have it already):** 19 | 20 | ```bash 21 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 22 | ``` 23 | 24 | **Install MongoDB Homebrew tap:** 25 | 26 | ```bash 27 | brew tap mongodb/brew 28 | brew install mongodb-community # installs latest MongoDB Community Server 5.0.x 29 | ``` 30 | 31 | ## Development 32 | *Use two terminal tabs to run the Husky Habits server and MongoDB database.* 33 | 34 | ### Run Server 35 | 36 | `yarn start` 37 | 38 | 39 | ### Run Database 40 | **Run `mongodb-community`:** 41 | - MacOS: `brew services start mongodb-community` 42 | 43 | **Connect to MongoDB server/database:** 44 | 45 | ```bash 46 | mongo 47 | use huskyhabits 48 | ``` -------------------------------------------------------------------------------- /routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | import passport, { authenticated } from '../authentication'; 3 | import { generate_authentication_cookies } from '../authentication'; 4 | 5 | const router: Router = Router(); 6 | 7 | // Auth Routes 8 | 9 | // hitting this endpoint sends the user to the google login page 10 | router.get( 11 | '/google', 12 | passport.authenticate('google', { scope: ['profile', 'email'] }), 13 | ); 14 | 15 | router.get( 16 | '/google/callback', 17 | passport.authenticate('google', { failureRedirect: '/auth/failed' }), 18 | function (req: Request, res: Response) { 19 | // Successful authentication, redirect home. 20 | res.redirect('/auth/success'); 21 | }, 22 | ); 23 | 24 | router.get('/failed', (req: Request, res: Response) => 25 | res.status(401).send('Login Failure'), 26 | ); 27 | 28 | router.get('/success', authenticated, (req: Request, res: Response) => { 29 | const id = req.user?._id; 30 | if (!id) { 31 | res.status(500).send(); 32 | return; 33 | } 34 | 35 | const auth_cookies = generate_authentication_cookies(id); 36 | res.redirect( 37 | `exp://login-redirect?cookies=${Buffer.from(auth_cookies).toString( 38 | 'base64', 39 | )}`, 40 | ); 41 | }); 42 | 43 | router.get('/logout', (req: Request, res: Response) => { 44 | req.session = null; 45 | req.logout(); 46 | res.redirect('/'); 47 | }); 48 | 49 | export default router; 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "huskyhabits-backend", 3 | "version": "1.0.0", 4 | "main": "server.ts", 5 | "scripts": { 6 | "build": "tsc", 7 | "start": "ts-node-dev server.ts", 8 | "test": "jest --watchAll --runInBand", 9 | "prepare": "husky install" 10 | }, 11 | "dependencies": { 12 | "@types/express-fileupload": "^1.2.2", 13 | "@types/passport": "^1.0.7", 14 | "@typescript-eslint/eslint-plugin": "^5.10.1", 15 | "@typescript-eslint/parser": "^5.0.0", 16 | "ajv": "^8.11.0", 17 | "cookie-session": "^2.0.0", 18 | "cookie-signature": "^1.2.0", 19 | "cors": "^2.8.5", 20 | "dotenv": "^14.3.2", 21 | "express": "^4.17.2", 22 | "express-fileupload": "^1.3.1", 23 | "keygrip": "^1.1.0", 24 | "mongoose": "^6.1.8", 25 | "node": "^17.4.0", 26 | "passport": "^0.5.2", 27 | "passport-google-oauth2": "^0.2.0", 28 | "superagent": "^7.1.2", 29 | "typescript": "^4.6.2" 30 | }, 31 | "devDependencies": { 32 | "@babel/core": "^7.17.5", 33 | "@babel/preset-env": "^7.16.11", 34 | "@babel/preset-typescript": "^7.16.7", 35 | "@types/cookie-session": "^2.0.44", 36 | "@types/cors": "^2.8.12", 37 | "@types/express": "^4.17.13", 38 | "@types/jest": "^27.4.1", 39 | "@types/mongoose": "^5.11.97", 40 | "@types/node": "^17.0.13", 41 | "@types/passport-google-oauth2": "^0.1.4", 42 | "babel-jest": "^27.5.1", 43 | "eslint": "^8.8.0", 44 | "eslint-config-airbnb": "^19.0.4", 45 | "eslint-config-airbnb-typescript": "^16.1.0", 46 | "eslint-config-prettier": "^8.3.0", 47 | "eslint-plugin-import": "^2.25.4", 48 | "eslint-plugin-jsx-a11y": "^6.5.1", 49 | "husky": "^7.0.4", 50 | "jest": "^27.5.1", 51 | "lint-staged": "^12.3.2", 52 | "prettier": "^2.5.1", 53 | "supertest": "^6.2.2", 54 | "ts-jest": "^27.1.3", 55 | "ts-node-dev": "^1.1.8" 56 | }, 57 | "lint-staged": { 58 | "*.{js,jsx,ts,tsx}": [ 59 | "prettier --write" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /controllers/profile.ts: -------------------------------------------------------------------------------- 1 | import { GETProfile, POSTCreateProfile } from '../types/apitypes/profile'; 2 | import ProfileModel from '../dbmodels/profile'; 3 | import IProfile, { ProfilePhoto } from '../types/dbtypes/profile'; 4 | import HTTPError from '../exceptions/HTTPError'; 5 | import { IUser } from '../types/dbtypes/user'; 6 | import { 7 | get_profile_by_id, 8 | create_profile, 9 | save_profile, 10 | get_profiles_by_user_id, 11 | } from '../repositories/profile'; 12 | import { Schema } from 'mongoose'; 13 | 14 | 15 | export class ProfileController { 16 | // Get the profile with the given id 17 | public profile_get = async (id: string): Promise => { 18 | let doc = await get_profile_by_id(id); 19 | 20 | return { 21 | _id: doc._id, 22 | user_id: doc.user_id, 23 | username: doc.username, 24 | bio: doc.bio, 25 | }; 26 | }; 27 | 28 | // Get a profile with the given user_id 29 | public profile_get_by_user_id = async ( 30 | user_id: Schema.Types.ObjectId, 31 | ): Promise => { 32 | let docs = await get_profiles_by_user_id(user_id); 33 | 34 | if (!docs) { 35 | const err = new HTTPError('User not found', 404); 36 | return Promise.reject(err); 37 | } 38 | 39 | if (docs.length == 0) { 40 | const err = new HTTPError('User has no profiles', 404); 41 | return Promise.reject(err); 42 | } 43 | 44 | return { 45 | _id: docs[0]._id, 46 | user_id: docs[0].user_id, 47 | username: docs[0].username, 48 | bio: docs[0].bio, 49 | }; 50 | }; 51 | 52 | // Create a profile with the provided info 53 | public profile_post = ( 54 | profile: POSTCreateProfile, 55 | user: IUser, 56 | ): Promise => { 57 | return create_profile(profile.username, profile.bio, user._id); 58 | }; 59 | 60 | // Does the profile belong to the specified user? 61 | public user_owns_profile = async ( 62 | profile_id: string, 63 | user: IUser, 64 | ): Promise => { 65 | let doc = await get_profile_by_id(profile_id); 66 | 67 | return doc.user_id.toString() == user._id.toString(); 68 | }; 69 | 70 | // Set the profile photo for the given user 71 | public set_profile_photo = async ( 72 | profile_id: string, 73 | photo: ProfilePhoto, 74 | ): Promise => { 75 | console.log(profile_id); 76 | let doc = await get_profile_by_id(profile_id); 77 | console.log(doc); 78 | if (!doc) return Promise.reject(new HTTPError('Profile not found', 404)); 79 | 80 | doc.photo = photo; 81 | return save_profile(new ProfileModel(doc)); 82 | }; 83 | 84 | // Retreive the profile photo for the specified user 85 | public get_profile_photo = async (profile_id: string): Promise => { 86 | let doc = await get_profile_by_id(profile_id); 87 | if (!doc) return Promise.reject(new HTTPError('Profile not found', 404)); 88 | 89 | return doc.photo; 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /routes/profile.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | import { GETProfile, POSTCreateProfile } from '../types/apitypes/profile'; 3 | import IProfile, { ProfilePhoto } from '../types/dbtypes/profile'; 4 | import HTTPError from '../exceptions/HTTPError'; 5 | import { sendError, sendValidationError } from '../exceptions/utils'; 6 | import { ajv } from '../types/validation'; 7 | import { 8 | ProfileController, 9 | } from '../controllers/profile'; 10 | import { UploadedFile } from 'express-fileupload'; 11 | import { authenticated } from '../authentication'; 12 | 13 | const router: Router = Router(); 14 | const profileController: ProfileController = new ProfileController(); 15 | 16 | // Gets the profile of the current logged in user 17 | router.get('/', authenticated, async (req: Request, res: Response) => { 18 | try { 19 | const profile: GETProfile = await profileController.profile_get_by_user_id(req.user!._id) 20 | res.status(200).json(profile); 21 | } catch (err: any) { 22 | sendError(err, res); 23 | } 24 | }); 25 | 26 | // Gets the profile with the given id 27 | router.get('/:profile_id', async (req: Request, res: Response) => { 28 | const profile_id = req.params.profile_id; 29 | try { 30 | const profile: GETProfile = await profileController.profile_get(profile_id); 31 | res.status(200).json(profile); 32 | } catch (err: any) { 33 | sendError(err, res); 34 | } 35 | }); 36 | 37 | // Creates a new profile with the given data 38 | router.post('/', authenticated, async (req: Request, res: Response) => { 39 | const validate = ajv.getSchema('POSTCreateProfile'); 40 | if (!validate) { 41 | res.sendStatus(500); 42 | return; 43 | } 44 | 45 | if (validate(req.body)) { 46 | try { 47 | const profile: IProfile = await profileController.profile_post(req.body, req.user!); 48 | res.status(200).send(profile); 49 | } catch (err: any) { 50 | if (err.name == 'MongoServerError' && err.code == 11000) { 51 | err = new HTTPError('Profile already exists', 400); 52 | } 53 | sendError(err, res); 54 | } 55 | } else { 56 | sendValidationError(validate, res); 57 | } 58 | }); 59 | 60 | // Gets the photo for a given profile 61 | router.get('/:profile_id/photo', async (req: Request, res: Response) => { 62 | const profile_id = req.params.profile_id; 63 | 64 | try { 65 | const photo = await profileController.get_profile_photo(profile_id); 66 | res.status(200).send(photo); 67 | } catch (err: any) { 68 | sendError(err, res); 69 | } 70 | }); 71 | 72 | // Sets the photo for a given profile 73 | router.post( 74 | '/:profile_id/photo', 75 | authenticated, 76 | async (req: Request, res: Response) => { 77 | const profile_id = req.params.profile_id; 78 | const ownsProfile = await profileController.user_owns_profile(profile_id, req.user!); 79 | if (!ownsProfile) { 80 | sendError(new HTTPError('Unauthorized', 401), res); 81 | return; 82 | } 83 | 84 | if (!req.files || Object.keys(req.files).length === 0) { 85 | sendError(new HTTPError('Bad Request', 400), res); 86 | return; 87 | } 88 | 89 | const photoFile: UploadedFile | UploadedFile[] = req.files.photo; 90 | 91 | if (Array.isArray(photoFile)) { 92 | sendError( 93 | new HTTPError('Multiple images received, one expected', 400), 94 | res, 95 | ); 96 | return; 97 | } 98 | 99 | const photo: ProfilePhoto = { 100 | data: photoFile.data, 101 | contentType: photoFile.mimetype, 102 | }; 103 | 104 | try { 105 | const profile = await profileController.set_profile_photo(profile_id, photo); 106 | res.status(200).send(profile); 107 | } catch (err: any) { 108 | sendError(err, res); 109 | } 110 | }, 111 | ); 112 | 113 | export default router; 114 | -------------------------------------------------------------------------------- /authentication.ts: -------------------------------------------------------------------------------- 1 | import passport, { Profile } from 'passport'; 2 | import google from 'passport-google-oauth2'; 3 | import { 4 | get_or_create_user as getOrCreateUser, 5 | get_user_by_id, 6 | } from './repositories/user'; 7 | import { Schema } from 'mongoose'; 8 | import { Request, Response, NextFunction, Application } from 'express'; 9 | import HTTPError from './exceptions/HTTPError'; 10 | import { IUser } from './types/dbtypes/user'; 11 | import cookieSession from 'cookie-session'; 12 | import { exit } from 'process'; 13 | import Keygrip from 'keygrip'; 14 | import mongoose from 'mongoose'; 15 | const cookie = require('cookie-signature'); 16 | 17 | // specify the User type in Passport as our IUser type 18 | declare global { 19 | namespace Express { 20 | interface User extends IUser {} 21 | } 22 | } 23 | 24 | // used by passport before putting the user in the cookie session; grabs just the ID for storage 25 | passport.serializeUser( 26 | ( 27 | _req: Request, 28 | user: IUser, 29 | done: (err: any, id: Schema.Types.ObjectId) => void, 30 | ) => { 31 | done(undefined, user._id); 32 | }, 33 | ); 34 | 35 | // used by passport when reading in cookies, so it can populate req.user with the user object based on the ID in the cookie 36 | passport.deserializeUser( 37 | (id: Schema.Types.ObjectId, done: (err: any, user?: IUser) => void) => { 38 | get_user_by_id(id) 39 | .then((user: IUser) => { 40 | done(undefined, user); 41 | }) 42 | .catch((err) => { 43 | done(err); 44 | }); 45 | }, 46 | ); 47 | 48 | if (!process.env.GOOGLECLIENTID || !process.env.GOOGLECLIENTSECRET) { 49 | console.error('.env file needs: GOOGLECLIENTID and GOOGLECLIENTSECRET.'); 50 | exit(1); 51 | } 52 | 53 | passport.use( 54 | new google.Strategy( 55 | { 56 | clientID: process.env.GOOGLECLIENTID || '', 57 | clientSecret: process.env.GOOGLECLIENTSECRET || '', 58 | callbackURL: 'http://localhost:3000/auth/google/callback', 59 | }, 60 | (accessToken: string, refreshToken: string, profile: Profile, done) => { 61 | if ( 62 | !profile.emails || 63 | !profile.name || 64 | !profile.name.givenName || 65 | !profile.name.familyName 66 | ) { 67 | done(new HTTPError('Google returned malformed data', 404)); 68 | return; 69 | } 70 | 71 | getOrCreateUser( 72 | profile.emails[0].value, 73 | profile.name.givenName, 74 | profile.name.familyName, 75 | ) 76 | .then((user: IUser) => { 77 | done(undefined, user); 78 | return; 79 | }) 80 | .catch((err) => { 81 | done(err, undefined); 82 | return; 83 | }); 84 | }, 85 | ), 86 | ); 87 | 88 | export const setupAuthentication = (app: Application) => { 89 | app.use( 90 | cookieSession({ 91 | name: 'husky-habits-auth', 92 | keys: new Keygrip(['key1', 'key2'], 'SHA256', 'base64'), 93 | }), 94 | ); 95 | 96 | app.use(passport.initialize()); 97 | app.use(passport.session()); 98 | }; 99 | 100 | // Auth middleware that checks if the user is logged in; use in all endpoints 101 | // that need req.user / an authenticated user to access 102 | export const authenticated = ( 103 | req: Request, 104 | res: Response, 105 | next: NextFunction, 106 | ) => { 107 | if (req.user) { 108 | next(); 109 | } else { 110 | res.sendStatus(401); 111 | } 112 | }; 113 | 114 | // Generates the cookies required to authenticate the user with the given id. 115 | export const generate_authentication_cookies = ( 116 | user_id: mongoose.Schema.Types.ObjectId, 117 | ): string => { 118 | const session = { 119 | passport: { 120 | user: user_id, 121 | }, 122 | }; 123 | 124 | const raw = JSON.stringify(session); 125 | 126 | const auth_cookie = Buffer.from(raw).toString('base64'); 127 | const signature = cookie 128 | .sign('husky-habits-auth=' + auth_cookie, 'key1') 129 | .split('.')[1] 130 | .replaceAll('/', '_') 131 | .replaceAll('+', '-'); 132 | 133 | return ( 134 | 'husky-habits-auth=' + auth_cookie + '; husky-habits-auth.sig=' + signature 135 | ); 136 | }; 137 | 138 | export default passport; 139 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs", /* Specify what module code is generated. */ 28 | // "rootDir": "src", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | "types": ["jest", "node"], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 75 | 76 | /* Type Checking */ 77 | "strict": true, /* Enable all strict type-checking options. */ 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/profiles.test.ts: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | import app from '../app'; 3 | import ProfileModel from '../dbmodels/profile'; 4 | import mongoose from 'mongoose'; 5 | import { IUser } from '../types/dbtypes/user'; 6 | import UserModel from '../dbmodels/user'; 7 | import { generate_authentication_cookies } from '../authentication'; 8 | import IProfile from '../types/dbtypes/profile'; 9 | import { ProfileController } from '../controllers/profile'; 10 | import { POSTCreateProfile } from '../types/apitypes/profile'; 11 | import fs from 'fs'; 12 | 13 | const profile_route: string = '/api/v1/profiles'; 14 | const mongoDB: string = `${process.env.DATABASE}-test` || ''; 15 | const connectOptions: any = { useNewUrlParser: true, useUnifiedTopology: true }; 16 | const profileController: ProfileController = new ProfileController(); 17 | 18 | // Connect to db before all tests 19 | beforeAll(async () => { 20 | try { 21 | await mongoose.connect(mongoDB, connectOptions); 22 | ProfileModel.deleteMany({}); 23 | UserModel.deleteMany({}); 24 | } catch (err: any) { 25 | console.error(err.message); 26 | process.exit(1); 27 | } 28 | }); 29 | 30 | // Disconnect from db after all tests 31 | afterAll(async () => { 32 | ProfileModel.deleteMany({}); 33 | UserModel.deleteMany({}); 34 | await mongoose.disconnect(); 35 | await mongoose.connection.close(); 36 | }); 37 | 38 | describe('Testing profile controller', () => { 39 | var cur_user: IUser; 40 | var other_user: IUser; 41 | var profile1: IProfile; 42 | var profile2: IProfile; 43 | const photo1 = `${__dirname}/test_photo1.jpg`; 44 | 45 | beforeEach(async () => { 46 | await ProfileModel.deleteMany({}); 47 | await UserModel.deleteMany({}); 48 | 49 | cur_user = await UserModel.create({ 50 | email: 'person@test.com', 51 | first_name: 'Test', 52 | last_name: 'Person', 53 | accounts: [{ acc_type: '', uid: '' }], 54 | }); 55 | 56 | other_user = await UserModel.create({ 57 | email: 'person2@test.com', 58 | first_name: 'Other', 59 | last_name: 'Guy', 60 | accounts: [{ acc_type: '', uid: '' }], 61 | }); 62 | 63 | profile1 = await ProfileModel.create({ 64 | user_id: cur_user._id, 65 | username: 'profile1', 66 | bio: 'Hello World!', 67 | }); 68 | 69 | profile2 = await ProfileModel.create({ 70 | user_id: other_user._id, 71 | username: 'profile2', 72 | bio: 'Goodbye World!', 73 | }); 74 | }); 75 | 76 | it('should get a profile by id', async () => { 77 | const res1 = await profileController.profile_get(profile1._id.toString()); 78 | expect(res1._id).toStrictEqual(profile1._id); 79 | expect(res1.user_id).toStrictEqual(profile1.user_id); 80 | expect(res1.username).toBe(profile1.username); 81 | expect(res1.bio).toBe(profile1.bio); 82 | 83 | const res2 = await profileController.profile_get(profile2._id.toString()); 84 | expect(res2._id).toStrictEqual(profile2._id); 85 | expect(res2.user_id).toStrictEqual(profile2.user_id); 86 | expect(res2.username).toBe(profile2.username); 87 | expect(res2.bio).toBe(profile2.bio); 88 | }); 89 | 90 | it('should get first profile by user id', async () => { 91 | const res1 = await profileController.profile_get_by_user_id(cur_user._id); 92 | expect(res1._id).toStrictEqual(profile1._id); 93 | expect(res1.user_id).toStrictEqual(profile1.user_id); 94 | expect(res1.username).toBe(profile1.username); 95 | expect(res1.bio).toBe(profile1.bio); 96 | 97 | const res2 = await profileController.profile_get_by_user_id(other_user._id); 98 | expect(res2._id).toStrictEqual(profile2._id); 99 | expect(res2.user_id).toStrictEqual(profile2.user_id); 100 | expect(res2.username).toBe(profile2.username); 101 | expect(res2.bio).toBe(profile2.bio); 102 | }); 103 | 104 | it("should fail to get user's profile if one doesn't exist", async () => { 105 | const new_user = await UserModel.create({ 106 | email: 'person3@test.com', 107 | first_name: 'Another', 108 | last_name: 'User', 109 | accounts: [{ acc_type: '', uid: '' }], 110 | }); 111 | 112 | expect( 113 | profileController.profile_get_by_user_id(new_user._id), 114 | ).rejects.toMatchObject({ 115 | code: 404, 116 | msg: 'User has no profiles', 117 | }); 118 | 119 | await ProfileModel.create({ 120 | username: 'profile3', 121 | user_id: new_user._id, 122 | }); 123 | 124 | expect( 125 | profileController.profile_get_by_user_id(new_user._id), 126 | ).resolves.toMatchObject({ 127 | username: 'profile3', 128 | user_id: new_user._id, 129 | }); 130 | }); 131 | 132 | it("profile get should fail if profile doesn't exist", async () => { 133 | await expect( 134 | profileController.profile_get(new mongoose.Types.ObjectId().toString()), 135 | ).rejects.toMatchObject({ code: 404, msg: 'Profile not found' }); 136 | await expect(profileController.profile_get('hello')).rejects.toMatchObject({ 137 | name: 'CastError', 138 | }); 139 | }); 140 | 141 | it('should create a new profile', async () => { 142 | const new_profile: POSTCreateProfile = { 143 | username: 'profile3', 144 | bio: 'New profile', 145 | }; 146 | 147 | const post_res = await profileController.profile_post( 148 | new_profile, 149 | cur_user, 150 | ); 151 | expect(post_res.user_id).toStrictEqual(cur_user._id); 152 | expect(post_res.username).toBe(new_profile.username); 153 | expect(post_res.bio).toBe(new_profile.bio); 154 | 155 | const get_res = await profileController.profile_get( 156 | post_res._id.toString(), 157 | ); 158 | expect(get_res._id).toStrictEqual(post_res._id); 159 | expect(get_res.username).toBe(new_profile.username); 160 | expect(get_res.bio).toBe(new_profile.bio); 161 | }); 162 | 163 | it('should fail to create a new profile if username in use', async () => { 164 | const new_profile: POSTCreateProfile = { 165 | username: 'profile2', 166 | bio: 'duplicate!', 167 | }; 168 | 169 | await expect( 170 | profileController.profile_post(new_profile, cur_user), 171 | ).rejects.toMatchObject({ 172 | name: 'MongoServerError', 173 | code: 11000, 174 | }); 175 | }); 176 | 177 | it('test user_owns_profile', () => { 178 | expect( 179 | profileController.user_owns_profile(profile1._id.toString(), cur_user), 180 | ).resolves.toBeTruthy(); 181 | expect( 182 | profileController.user_owns_profile(profile1._id.toString(), other_user), 183 | ).resolves.toBeFalsy(); 184 | expect( 185 | profileController.user_owns_profile(profile2._id.toString(), cur_user), 186 | ).resolves.toBeFalsy(); 187 | expect( 188 | profileController.user_owns_profile(profile2._id.toString(), other_user), 189 | ).resolves.toBeTruthy(); 190 | }); 191 | 192 | it('should set a photo for a profile', async () => { 193 | const photo = { data: fs.readFileSync(photo1), contentType: 'image/jpg' }; 194 | const profile = await profileController.set_profile_photo( 195 | profile1._id.toString(), 196 | photo, 197 | ); 198 | expect(profile._id).toStrictEqual(profile1._id); 199 | expect(profile.photo).toMatchObject(photo); 200 | }); 201 | 202 | it('should get the photo for a profile', async () => { 203 | const photo = { data: fs.readFileSync(photo1), contentType: 'image/jpg' }; 204 | await profileController.set_profile_photo(profile1._id.toString(), photo); 205 | 206 | const result = await profileController.get_profile_photo( 207 | profile1._id.toString(), 208 | ); 209 | expect(result).toMatchObject(photo); 210 | 211 | const noPhotoResult = await profileController.get_profile_photo( 212 | profile2._id.toString(), 213 | ); 214 | expect(noPhotoResult).toMatchObject({}); 215 | }); 216 | 217 | it('should send an error if setting or getting photo for nonexistant profile id', () => { 218 | expect( 219 | profileController.get_profile_photo( 220 | new mongoose.Types.ObjectId().toString(), 221 | ), 222 | ).rejects.toMatchObject({ code: 404, msg: 'Profile not found' }); 223 | const photo = { data: fs.readFileSync(photo1), contentType: 'image/jpg' }; 224 | expect( 225 | profileController.set_profile_photo( 226 | new mongoose.Types.ObjectId().toString(), 227 | photo, 228 | ), 229 | ).rejects.toMatchObject({ code: 404, msg: 'Profile not found' }); 230 | }); 231 | }); 232 | 233 | describe('Testing profile POST', () => { 234 | var cur_user: IUser; 235 | 236 | beforeEach(async () => { 237 | await ProfileModel.deleteMany({}); 238 | await UserModel.deleteMany({}); 239 | 240 | cur_user = await UserModel.create({ 241 | email: 'person@test.com', 242 | first_name: 'Test', 243 | last_name: 'Person', 244 | accounts: [{ acc_type: '', uid: '' }], 245 | }); 246 | }); 247 | 248 | it('should create a new profile', async () => { 249 | const user_profile = { 250 | username: 'profile1', 251 | bio: 'This is my bio', 252 | }; 253 | 254 | const res = await request(app) 255 | .post(profile_route) 256 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 257 | .send(user_profile); 258 | 259 | expect(res.status).toBe(200); 260 | }); 261 | 262 | it('should create 2 new profiles for the same user', async () => { 263 | const user_profile1 = { 264 | username: 'profile1', 265 | }; 266 | 267 | const user_profile2 = { 268 | username: 'profile2', 269 | }; 270 | 271 | let res = await request(app) 272 | .post(profile_route) 273 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 274 | .send(user_profile1); 275 | expect(res.status).toBe(200); 276 | res = await request(app) 277 | .post(profile_route) 278 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 279 | .send(user_profile2); 280 | expect(res.status).toBe(200); 281 | }); 282 | 283 | it('should send error message for duplicate usernames', async () => { 284 | const user_profile1 = { 285 | username: 'profile1', 286 | }; 287 | 288 | const user_profile2 = { 289 | username: 'profile1', 290 | }; 291 | 292 | let res = await request(app) 293 | .post(profile_route) 294 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 295 | .send(user_profile1); 296 | expect(res.statusCode).toBe(200); 297 | res = await request(app) 298 | .post(profile_route) 299 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 300 | .send(user_profile2); 301 | expect(res.statusCode).toBe(400); 302 | expect(res.text).toBe('Profile already exists'); 303 | }); 304 | 305 | it('should send error message if we send undefined', async () => { 306 | const res = await request(app) 307 | .post(profile_route) 308 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 309 | .send(undefined); 310 | expect(res.statusCode).toBe(400); 311 | }); 312 | 313 | it('should send error message if we send null', async () => { 314 | const res = await request(app) 315 | .post(profile_route) 316 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 317 | .send(null); 318 | expect(res.statusCode).toBe(400); 319 | }); 320 | 321 | it('should send error for extra fields in input', async () => { 322 | const bad_profile = { 323 | _id: new mongoose.Types.ObjectId().toString(), 324 | username: 'profile1', 325 | }; 326 | 327 | const res = await request(app) 328 | .post(profile_route) 329 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 330 | .send(bad_profile); 331 | 332 | expect(res.status).toBe(400); 333 | expect(res.text).toBe( 334 | 'Input parsing error: must NOT have additional properties', 335 | ); 336 | }); 337 | 338 | it('should send error for missing fields in input', async () => { 339 | const bad_profile1 = { 340 | bio: 'Hello World', 341 | }; 342 | 343 | let res = await request(app) 344 | .post(profile_route) 345 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 346 | .send(bad_profile1); 347 | expect(res.status).toBe(400); 348 | expect(res.text).toBe( 349 | "Input parsing error: must have required property 'username'", 350 | ); 351 | }); 352 | 353 | it('should send error if posting when not authenticated', async () => { 354 | const user_profile = { 355 | username: 'profile1', 356 | bio: 'This is my bio', 357 | }; 358 | 359 | const res = await request(app).post(profile_route).send(user_profile); 360 | expect(res.status).toBe(401); 361 | expect(res.text).toBe('Unauthorized'); 362 | }); 363 | }); 364 | 365 | describe('Testing profile GET', () => { 366 | let cur_user: IUser; 367 | 368 | beforeEach(async () => { 369 | await ProfileModel.deleteMany({}); 370 | await UserModel.deleteMany({}); 371 | 372 | cur_user = await UserModel.create({ 373 | email: 'person@test.com', 374 | first_name: 'Test', 375 | last_name: 'Person', 376 | accounts: [{ acc_type: '', uid: '' }], 377 | }); 378 | }); 379 | 380 | it('should return the profile of the given user', async () => { 381 | const profile_to_get = { 382 | username: 'profile1', 383 | bio: 'This is my bio', 384 | }; 385 | 386 | let response = await request(app) 387 | .post(profile_route) 388 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 389 | .send(profile_to_get); 390 | const profile_id = response.body._id; 391 | 392 | response = await request(app).get(`${profile_route}/${profile_id}`); 393 | expect(response.body._id).toStrictEqual(profile_id); 394 | expect(response.body.username).toBe(profile_to_get.username); 395 | expect(response.body.bio).toBe(profile_to_get.bio); 396 | }); 397 | 398 | it('should return correct user with multiple entries', async () => { 399 | const user_profile_1 = { 400 | username: 'test1', 401 | }; 402 | const user_profile_2 = { 403 | username: 'test2', 404 | }; 405 | 406 | const res1 = await request(app) 407 | .post(profile_route) 408 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 409 | .send(user_profile_1); 410 | const res2 = await request(app) 411 | .post(profile_route) 412 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 413 | .send(user_profile_2); 414 | 415 | const id1 = res1.body._id; 416 | const id2 = res2.body._id; 417 | 418 | let response = await request(app).get(`${profile_route}/${id1}`); 419 | expect(response.statusCode).toBe(200); 420 | expect(response.body.username).toBe(user_profile_1.username); 421 | expect(response.body._id).toStrictEqual(id1.toString()); 422 | 423 | response = await request(app).get(`${profile_route}/${id2}`); 424 | expect(response.statusCode).toBe(200); 425 | expect(response.body.username).toBe(user_profile_2.username); 426 | expect(response.body._id).toStrictEqual(id2.toString()); 427 | }); 428 | 429 | it('should send 404 code if not found', async () => { 430 | const route: string = `${profile_route}/${new mongoose.Types.ObjectId()}`; 431 | const response = await request(app).get(route); 432 | expect(response.statusCode).toBe(404); 433 | expect(response.text).toBe('Profile not found'); 434 | }); 435 | 436 | it("should send 404 if id doesn't match any id in database", async () => { 437 | const new_profile = { 438 | username: 'Bob the Builder', 439 | }; 440 | await request(app) 441 | .post(profile_route) 442 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 443 | .send(new_profile); 444 | 445 | const response = await request(app).get( 446 | `${profile_route}/${new mongoose.Types.ObjectId()}`, 447 | ); 448 | expect(response.statusCode).toBe(404); 449 | expect(response.text).toBe('Profile not found'); 450 | }); 451 | 452 | it('should send error if unauthorized user requests their profile', async () => { 453 | const new_profile = { 454 | username: 'Bob the Builder', 455 | }; 456 | await request(app) 457 | .post(profile_route) 458 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 459 | .send(new_profile); 460 | 461 | const response = await request(app).get(`${profile_route}`); 462 | expect(response.statusCode).toBe(401); 463 | }); 464 | 465 | it('should get one profile of the current user', async () => { 466 | const new_profile1 = { 467 | username: 'Bob the Builder', 468 | bio: 'I build', 469 | }; 470 | const new_profile2 = { 471 | username: 'Secret guy', 472 | bio: 'I am secret', 473 | }; 474 | await request(app) 475 | .post(profile_route) 476 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 477 | .send(new_profile1); 478 | await request(app) 479 | .post(profile_route) 480 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 481 | .send(new_profile2); 482 | 483 | const response = await request(app) 484 | .get(`${profile_route}`) 485 | .set('Cookie', generate_authentication_cookies(cur_user._id)); 486 | expect(response.statusCode).toBe(200); 487 | expect( 488 | (response.body.username == new_profile1.username && 489 | response.body.bio == new_profile1.bio) || 490 | (response.body.username == new_profile2.username && 491 | response.body.bio == new_profile2.bio), 492 | ).toBeTruthy(); 493 | }); 494 | }); 495 | 496 | describe('Testing profile photo POST and GET', () => { 497 | var cur_user: IUser; 498 | var other_user: IUser; 499 | var profile1: IProfile; 500 | var profile2: IProfile; 501 | var photo1: string; 502 | var photo2: string; 503 | 504 | beforeEach(async () => { 505 | await ProfileModel.deleteMany({}); 506 | await UserModel.deleteMany({}); 507 | 508 | cur_user = await UserModel.create({ 509 | email: 'person@test.com', 510 | first_name: 'Test', 511 | last_name: 'Person', 512 | accounts: [{ acc_type: '', uid: '' }], 513 | }); 514 | 515 | other_user = await UserModel.create({ 516 | email: 'person2@test.com', 517 | first_name: 'Other', 518 | last_name: 'Guy', 519 | accounts: [{ acc_type: '', uid: '' }], 520 | }); 521 | 522 | profile1 = await ProfileModel.create({ 523 | user_id: cur_user._id, 524 | username: 'profile1', 525 | bio: 'Hello World!', 526 | }); 527 | 528 | profile2 = await ProfileModel.create({ 529 | user_id: other_user._id, 530 | username: 'profile2', 531 | bio: 'Goodbye World!', 532 | }); 533 | 534 | photo1 = `${__dirname}/test_photo1.jpg`; 535 | photo2 = `${__dirname}/test_photo2.jpg`; 536 | }); 537 | 538 | it("should fail to post photo if user doesn't own profile", async () => { 539 | let response = await request(app).post( 540 | `${profile_route}/${profile1._id}/photo`, 541 | ); 542 | expect(response.statusCode).toBe(401); 543 | 544 | response = await request(app) 545 | .post(`${profile_route}/${profile2._id}/photo`) 546 | .set('Cookie', generate_authentication_cookies(cur_user._id)); 547 | expect(response.statusCode).toBe(401); 548 | }); 549 | 550 | it('should successfully set the photo for a profile', async () => { 551 | let response = await request(app) 552 | .post(`${profile_route}/${profile1._id}/photo`) 553 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 554 | .attach('photo', photo1); 555 | 556 | expect(response.statusCode).toBe(200); 557 | expect(response.body._id.toString()).toBe(profile1._id.toString()); 558 | expect(response.body.photo.contentType).toBe('image/jpeg'); 559 | expect(response.body.photo.data).toMatchObject({ 560 | type: 'Buffer', 561 | data: fs.readFileSync(photo1), 562 | }); 563 | 564 | response = await request(app) 565 | .post(`${profile_route}/${profile2._id}/photo`) 566 | .set('Cookie', generate_authentication_cookies(other_user._id)) 567 | .attach('photo', photo2); 568 | 569 | expect(response.statusCode).toBe(200); 570 | expect(response.body._id.toString()).toBe(profile2._id.toString()); 571 | expect(response.body.photo.contentType).toBe('image/jpeg'); 572 | expect(response.body.photo.data).toMatchObject({ 573 | type: 'Buffer', 574 | data: fs.readFileSync(photo2), 575 | }); 576 | }); 577 | 578 | it('should successfully get the photo for a profile', async () => { 579 | await request(app) 580 | .post(`${profile_route}/${profile1._id}/photo`) 581 | .set('Cookie', generate_authentication_cookies(cur_user._id)) 582 | .attach('photo', photo1); 583 | 584 | let response = await request(app).get( 585 | `${profile_route}/${profile1._id}/photo`, 586 | ); 587 | 588 | expect(response.statusCode).toBe(200); 589 | expect(response.body.contentType).toBe('image/jpeg'); 590 | expect(response.body.data).toMatchObject({ 591 | type: 'Buffer', 592 | data: fs.readFileSync(photo1), 593 | }); 594 | }); 595 | 596 | it('test getting the photo for a profile with no photo', async () => { 597 | let response = await request(app).get( 598 | `${profile_route}/${profile2._id}/photo`, 599 | ); 600 | 601 | expect(response.statusCode).toBe(200); 602 | expect(response.body).toMatchObject({}); 603 | }); 604 | }); 605 | --------------------------------------------------------------------------------