-
Notifications
You must be signed in to change notification settings - Fork 87
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: migrate GET /adminform endpoint to Typescript (#575)
* 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
Showing
12 changed files
with
472 additions
and
139 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
68 changes: 68 additions & 0 deletions
68
src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }) | ||
}) | ||
}) | ||
}) |
92 changes: 92 additions & 0 deletions
92
src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}, | ||
) | ||
}) | ||
) | ||
} |
Oops, something went wrong.