Skip to content

Commit

Permalink
refactor: migrate GET /adminform endpoint to Typescript (#575)
Browse files Browse the repository at this point in the history
* refactor(FormModel): move types into form.types

* feat(FormModel): add getDashboardForms static function

* refactor: use form model static function to list dashboard forms

* feat(AdminFormSvc): add getDashboardForms service function

* feat(AdminFormCtl): add handler for GET /adminforms endpoint

* refactor(AdminFormRoutes): use new handleListDashboardForms impl

* test(AdminFormSvc): add tests for getDashboardForms

* feat(FormModel): return lean object when retrieving dashboard forms

* test(FormModel): add tests for getDashboardForms static fn

* test: remove test cases for removed list form fn

* test(AdminFormCtl): add tests

* feat(AdminFormsRoutes): update list flow to use middleware

* test(FormModel): add stronger test case to test return array
  • Loading branch information
karrui authored Nov 9, 2020
1 parent aaacd5b commit 2dbf0bd
Show file tree
Hide file tree
Showing 12 changed files with 472 additions and 139 deletions.
34 changes: 0 additions & 34 deletions src/app/controllers/admin-forms.server.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -422,40 +422,6 @@ function makeModule(connection) {
}
})
},
/**
* List of all of user-created (and collaborated-on) forms
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
list: function (req, res) {
let Form = getFormModel(connection)
// List forms when either the user is an admin or collaborator
let searchFields = [
{ 'permissionList.email': req.session.user.email },
{ admin: req.session.user },
]
let returnedFields = '_id title admin lastModified status form_fields'

Form.find({ $or: searchFields }, returnedFields)
.sort('-lastModified')
.populate({
path: 'admin',
populate: {
path: 'agency',
},
})
.exec(function (err, forms) {
if (err) {
return respondOnMongoError(req, res, err)
} else if (!forms) {
return res.status(StatusCodes.NOT_FOUND).json({
message: 'No user-created and collaborated-on forms found',
})
}
let activeForms = forms.filter((form) => form.status !== 'ARCHIVED')
return res.json(activeForms)
})
},
/**
* Return form feedback matching query
* @param {Object} req - Express request object
Expand Down
42 changes: 31 additions & 11 deletions src/app/models/form.server.model.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import BSON from 'bson-ext'
import { compact, filter, pick, uniq } from 'lodash'
import { Model, Mongoose, Schema, SchemaOptions } from 'mongoose'
import { Mongoose, Schema, SchemaOptions } from 'mongoose'
import validator from 'validator'

import { FORM_DUPLICATE_KEYS } from '../../shared/constants'
import {
AuthType,
BasicField,
Colors,
DashboardFormView,
FormLogoState,
FormOtpData,
IEmailFormModel,
IEmailFormSchema,
IEncryptedFormModel,
IEncryptedFormSchema,
IForm,
IFormModel,
IFormSchema,
IPopulatedForm,
LogicType,
Expand Down Expand Up @@ -86,23 +90,13 @@ const formSchemaOptions: SchemaOptions = {
},
}

export interface IFormModel extends Model<IFormSchema> {
getOtpData(formId: string): Promise<FormOtpData | null>
getFullFormById(formId: string): Promise<IPopulatedForm | null>
deactivateById(formId: string): Promise<IFormSchema | null>
}

type IEncryptedFormModel = Model<IEncryptedFormSchema> & IFormModel

const EncryptedFormSchema = new Schema<IEncryptedFormSchema>({
publicKey: {
type: String,
required: true,
},
})

type IEmailFormModel = Model<IEmailFormSchema> & IFormModel

// Converts '[email protected], [email protected]' to ['[email protected]', '[email protected]']
function transformEmailString(v: string): string[] {
return v
Expand Down Expand Up @@ -501,6 +495,32 @@ const compileFormModel = (db: Mongoose): IFormModel => {
return form.save()
}

FormSchema.statics.getDashboardForms = async function (
this: IFormModel,
userId: IUserSchema['_id'],
userEmail: IUserSchema['email'],
): Promise<DashboardFormView[]> {
return (
this.find()
// List forms when either the user is an admin or collaborator.
.or([{ 'permissionList.email': userEmail }, { admin: userId }])
// Filter out archived forms.
.where('status')
.ne(Status.Archived)
// Project selected fields.
.select('_id title admin lastModified status form_fields')
.sort('-lastModified')
.populate({
path: 'admin',
populate: {
path: 'agency',
},
})
.lean()
.exec()
)
}

// Hooks
FormSchema.pre<IFormSchema>('validate', async function (next) {
// Reject save if form document is too large
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { errAsync, okAsync } from 'neverthrow'
import { mocked } from 'ts-jest/utils'

import { DatabaseError } from 'src/app/modules/core/core.errors'
import { MissingUserError } from 'src/app/modules/user/user.errors'

import expressHandler from 'tests/unit/backend/helpers/jest-express'

import { handleListDashboardForms } from '../admin-form.controller'
import * as AdminFormService from '../admin-form.service'

jest.mock('../admin-form.service')
const MockAdminFormService = mocked(AdminFormService)

describe('admin-form.controller', () => {
describe('handleListDashboardForms', () => {
const MOCK_REQ = expressHandler.mockRequest({
session: {
user: {
_id: 'exists',
},
},
})
it('should return 200 with list of forms', async () => {
// Arrange
const mockRes = expressHandler.mockResponse()
// Mock return array.
MockAdminFormService.getDashboardForms.mockReturnValueOnce(okAsync([]))

// Act
await handleListDashboardForms(MOCK_REQ, mockRes, jest.fn())

// Assert
expect(mockRes.json).toHaveBeenCalledWith([])
})

it('should return 422 on MissingUserError', async () => {
// Arrange
const mockRes = expressHandler.mockResponse()
MockAdminFormService.getDashboardForms.mockReturnValueOnce(
errAsync(new MissingUserError()),
)

// Act
await handleListDashboardForms(MOCK_REQ, mockRes, jest.fn())

// Assert
expect(mockRes.status).toBeCalledWith(422)
expect(mockRes.json).toBeCalledWith({ message: 'User not found' })
})

it('should return 500 when database error occurs', async () => {
// Arrange
const mockRes = expressHandler.mockResponse()
const mockErrorString = 'something went wrong'
MockAdminFormService.getDashboardForms.mockReturnValueOnce(
errAsync(new DatabaseError(mockErrorString)),
)

// Act
await handleListDashboardForms(MOCK_REQ, mockRes, jest.fn())

// Assert
expect(mockRes.status).toBeCalledWith(500)
expect(mockRes.json).toBeCalledWith({ message: mockErrorString })
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import mongoose from 'mongoose'
import { errAsync, okAsync } from 'neverthrow'
import { mocked } from 'ts-jest/utils'

import getFormModel from 'src/app/models/form.server.model'
import { DatabaseError } from 'src/app/modules/core/core.errors'
import { MissingUserError } from 'src/app/modules/user/user.errors'
import * as UserService from 'src/app/modules/user/user.service'
import { DashboardFormView, IPopulatedUser, IUserSchema } from 'src/types'

import { getDashboardForms } from '../admin-form.service'

const FormModel = getFormModel(mongoose)

jest.mock('src/app/modules/user/user.service')
const MockUserService = mocked(UserService)

describe('admin-form.service', () => {
describe('getDashboardForms', () => {
it('should return list of forms user is authorized to view', async () => {
// Arrange
const mockUserId = 'mockUserId'
const mockUser: Partial<IUserSchema> = {
email: '[email protected]',
_id: mockUserId,
}
const mockDashboardForms: DashboardFormView[] = [
{
admin: {} as IPopulatedUser,
title: 'test form 1',
},
{
admin: {} as IPopulatedUser,
title: 'test form 2',
},
]
// Mock user admin success.
MockUserService.findAdminById.mockReturnValueOnce(
okAsync(mockUser as IUserSchema),
)
const getSpy = jest
.spyOn(FormModel, 'getDashboardForms')
.mockResolvedValueOnce(mockDashboardForms)

// Act
const actualResult = await getDashboardForms(mockUserId)

// Assert
expect(getSpy).toHaveBeenCalledWith(mockUserId, mockUser.email)
expect(actualResult.isOk()).toEqual(true)
expect(actualResult._unsafeUnwrap()).toEqual(mockDashboardForms)
})

it('should return MissingUserError when user with userId does not exist', async () => {
// Arrange
const expectedError = new MissingUserError('not found')
MockUserService.findAdminById.mockReturnValueOnce(errAsync(expectedError))

// Act
const actualResult = await getDashboardForms('any')

// Assert
expect(actualResult.isErr()).toEqual(true)
// Error should passthrough.
expect(actualResult._unsafeUnwrapErr()).toEqual(expectedError)
})

it('should return DatabaseError when error occurs whilst querying the database', async () => {
// Arrange
const mockUserId = 'mockUserId'
const mockUser: Partial<IUserSchema> = {
email: '[email protected]',
_id: mockUserId,
}
// Mock user admin success.
MockUserService.findAdminById.mockReturnValueOnce(
okAsync(mockUser as IUserSchema),
)
const getSpy = jest
.spyOn(FormModel, 'getDashboardForms')
.mockRejectedValueOnce(new Error('some error'))

// Act
const actualResult = await getDashboardForms(mockUserId)

// Assert
expect(getSpy).toHaveBeenCalledWith(mockUserId, mockUser.email)
expect(actualResult.isErr()).toEqual(true)
expect(actualResult._unsafeUnwrapErr()).toEqual(new DatabaseError())
})
})
})
38 changes: 38 additions & 0 deletions src/app/modules/form/admin-form/admin-form.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { RequestHandler } from 'express'

import { createLoggerWithLabel } from '../../../../config/logger'

import { getDashboardForms } from './admin-form.service'
import { mapRouteError } from './admin-form.utils'

const logger = createLoggerWithLabel(module)

/**
* Handler for GET /adminform endpoint.
* @security session
*
* @returns 200 with list of forms user can access when list is retrieved successfully
* @returns 422 when user of given id cannnot be found in the database
* @returns 500 when database errors occur
*/
export const handleListDashboardForms: RequestHandler = async (req, res) => {
const authedUserId = (req.session as Express.AuthedSession).user._id
const dashboardResult = await getDashboardForms(authedUserId)

if (dashboardResult.isErr()) {
const { error } = dashboardResult
logger.error({
message: 'Error listing dashboard forms',
meta: {
action: 'handleListDashboardForms',
userId: authedUserId,
},
error,
})
const { errorMessage, statusCode } = mapRouteError(error)
return res.status(statusCode).json({ message: errorMessage })
}

// Success.
return res.json(dashboardResult.value)
}
47 changes: 47 additions & 0 deletions src/app/modules/form/admin-form/admin-form.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import mongoose from 'mongoose'
import { ResultAsync } from 'neverthrow'

import { createLoggerWithLabel } from '../../../../config/logger'
import { DashboardFormView } from '../../../../types'
import getFormModel from '../../../models/form.server.model'
import { DatabaseError } from '../../core/core.errors'
import { MissingUserError } from '../../user/user.errors'
import { findAdminById } from '../../user/user.service'

const logger = createLoggerWithLabel(module)
const FormModel = getFormModel(mongoose)

/**
* Retrieves a list of forms that the user of the given userId can access in
* their dashboard.
* @param userId the id of the user to retrieve accessible forms for.
* @returns ok(DashboardFormViewList)
* @returns err(MissingUserError) if user with userId does not exist in the database
* @returns err(DatabaseError) if error occurs whilst querying the database
*/
export const getDashboardForms = (
userId: string,
): ResultAsync<DashboardFormView[], MissingUserError | DatabaseError> => {
// Step 1: Verify user exists.
return (
findAdminById(userId)
// Step 2: Retrieve lists users are authorized to see.
.andThen((admin) => {
return ResultAsync.fromPromise(
FormModel.getDashboardForms(userId, admin.email),
(error) => {
logger.error({
message: 'Database error when retrieving admin dashboard forms',
meta: {
action: 'getDashboardForms',
userId,
},
error,
})

return new DatabaseError()
},
)
})
)
}
Loading

0 comments on commit 2dbf0bd

Please sign in to comment.