diff --git a/src/app/controllers/admin-forms.server.controller.js b/src/app/controllers/admin-forms.server.controller.js index 46c907e1e6..a2bb30941e 100644 --- a/src/app/controllers/admin-forms.server.controller.js +++ b/src/app/controllers/admin-forms.server.controller.js @@ -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 diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 9537330ecf..035349a756 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -1,6 +1,6 @@ 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' @@ -8,11 +8,15 @@ import { AuthType, BasicField, Colors, + DashboardFormView, FormLogoState, FormOtpData, + IEmailFormModel, IEmailFormSchema, + IEncryptedFormModel, IEncryptedFormSchema, IForm, + IFormModel, IFormSchema, IPopulatedForm, LogicType, @@ -86,14 +90,6 @@ const formSchemaOptions: SchemaOptions = { }, } -export interface IFormModel extends Model { - getOtpData(formId: string): Promise - getFullFormById(formId: string): Promise - deactivateById(formId: string): Promise -} - -type IEncryptedFormModel = Model & IFormModel - const EncryptedFormSchema = new Schema({ publicKey: { type: String, @@ -101,8 +97,6 @@ const EncryptedFormSchema = new Schema({ }, }) -type IEmailFormModel = Model & IFormModel - // Converts 'test@hotmail.com, test@gmail.com' to ['test@hotmail.com', 'test@gmail.com'] 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 { + 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('validate', async function (next) { // Reject save if form document is too large diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts new file mode 100644 index 0000000000..ee57de02eb --- /dev/null +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -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 }) + }) + }) +}) diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts new file mode 100644 index 0000000000..6907f68c77 --- /dev/null +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -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 = { + email: 'MOCK_EMAIL@example.com', + _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 = { + email: 'MOCK_EMAIL@example.com', + _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()) + }) + }) +}) diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts new file mode 100644 index 0000000000..873f81a811 --- /dev/null +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -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) +} diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts new file mode 100644 index 0000000000..a1b01eac6a --- /dev/null +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -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 => { + // 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() + }, + ) + }) + ) +} diff --git a/src/app/modules/form/admin-form/admin-form.utils.ts b/src/app/modules/form/admin-form/admin-form.utils.ts new file mode 100644 index 0000000000..25d5ef6739 --- /dev/null +++ b/src/app/modules/form/admin-form/admin-form.utils.ts @@ -0,0 +1,45 @@ +import { StatusCodes } from 'http-status-codes' + +import { createLoggerWithLabel } from '../../../../config/logger' +import { ApplicationError, DatabaseError } from '../../core/core.errors' +import { ErrorResponseData } from '../../core/core.types' +import { MissingUserError } from '../../user/user.errors' + +const logger = createLoggerWithLabel(module) + +/** + * Handler to map ApplicationErrors to their correct status code and error + * messages. + * @param error The error to retrieve the status codes and error messages + * @param coreErrorMessage Any error message to return instead of the default core error message, if any + */ +export const mapRouteError = ( + error: ApplicationError, + coreErrorMessage?: string, +): ErrorResponseData => { + switch (error.constructor) { + case MissingUserError: + return { + statusCode: StatusCodes.UNPROCESSABLE_ENTITY, + errorMessage: error.message, + } + case DatabaseError: + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorMessage: coreErrorMessage ?? error.message, + } + default: + logger.error({ + message: 'Unknown route error observed', + meta: { + action: 'mapRouteError', + }, + error, + }) + + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorMessage: 'Something went wrong. Please try again.', + } + } +} diff --git a/src/app/modules/user/user.service.ts b/src/app/modules/user/user.service.ts index 4c7c428821..1f27e65766 100644 --- a/src/app/modules/user/user.service.ts +++ b/src/app/modules/user/user.service.ts @@ -239,7 +239,6 @@ export const retrieveUser = ( ) } -// Private helper functions /** * Retrieves the user with the given id. * @param userId the id of the user to retrieve @@ -247,7 +246,7 @@ export const retrieveUser = ( * @returns err(DatabaseError) if database errors occurs whilst retrieving user * @returns err(MissingUserError) if user does not exist in the database */ -const findAdminById = ( +export const findAdminById = ( userId: string, ): ResultAsync => { return ResultAsync.fromPromise(UserModel.findById(userId).exec(), (error) => { @@ -268,6 +267,7 @@ const findAdminById = ( }) } +// Private helper functions /** * Hashes both the given otp and contact data. * @param otp the otp to hash diff --git a/src/app/routes/admin-forms.server.routes.js b/src/app/routes/admin-forms.server.routes.js index 14f1853ee6..9e0155bce1 100644 --- a/src/app/routes/admin-forms.server.routes.js +++ b/src/app/routes/admin-forms.server.routes.js @@ -15,6 +15,7 @@ let encryptSubmissions = require('../../app/controllers/encrypt-submissions.serv let PERMISSIONS = require('../utils/permission-levels').default const spcpFactory = require('../factories/spcp-myinfo.factory') const webhookVerifiedContentFactory = require('../factories/webhook-verified-content.factory') +const AdminFormController = require('../modules/form/admin-form/admin-form.controller') const { withUserAuthentication } = require('../modules/auth/auth.middlewares') const emailValOpts = { @@ -86,7 +87,7 @@ module.exports = function (app) { */ app .route('/adminform') - .get(withUserAuthentication, adminForms.list) + .get(withUserAuthentication, AdminFormController.handleListDashboardForms) .post(withUserAuthentication, adminForms.create) /** diff --git a/src/types/form.ts b/src/types/form.ts index f69e09b2b8..dd10ec360d 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -1,4 +1,4 @@ -import { Document } from 'mongoose' +import { Document, Model } from 'mongoose' import { IFieldSchema, MyInfoAttribute } from './field' import { ILogicSchema } from './form_logic' @@ -126,3 +126,24 @@ export interface IEmailForm extends IForm { } export type IEmailFormSchema = IEmailForm & IFormSchema + +export interface IFormModel extends Model { + getOtpData(formId: string): Promise + getFullFormById(formId: string): Promise + deactivateById(formId: string): Promise + getDashboardForms( + userId: IUserSchema['_id'], + userEmail: IUserSchema['email'], + ): Promise +} + +export type IEncryptedFormModel = Model & IFormModel +export type IEmailFormModel = Model & IFormModel +// Typing for the shape of the form document subset that is returned to the +// frontend when admin lists their available forms in their dashboard. +export type DashboardFormView = Pick< + IFormSchema, + 'title' | 'admin' | 'lastModified' | 'status' | 'form_fields' +> & { + admin: IPopulatedUser +} diff --git a/tests/unit/backend/controllers/admin-forms.server.controller.spec.js b/tests/unit/backend/controllers/admin-forms.server.controller.spec.js index edc5e4032b..f28ca9165f 100644 --- a/tests/unit/backend/controllers/admin-forms.server.controller.spec.js +++ b/tests/unit/backend/controllers/admin-forms.server.controller.spec.js @@ -292,65 +292,6 @@ describe('Admin-Forms Controller', () => { }) }) - describe('list', () => { - it('should fetch forms with corresponding admin or collaborators and sorted by last modified', async (done) => { - const currentAdmin = testUser - // Insert additional user into User collection. - const collabAdmin = await User.create({ - email: 'test1@test.gov.sg', - _id: mongoose.Types.ObjectId('000000000002'), - agency: testAgency._id, - }) - // Is admin - let form1 = new Form({ - title: 'Test Form1', - emails: currentAdmin.email, - admin: currentAdmin._id, - }).save() - // Is collab - let form2 = new Form({ - title: 'Test Form2', - emails: collabAdmin.email, - admin: collabAdmin._id, - permissionList: [roles.collaborator(currentAdmin.email)], - }).save() - // Should not be fetched since archived - let form3 = new Form({ - title: 'Test Form3', - emails: currentAdmin.email, - admin: currentAdmin._id, - status: 'ARCHIVED', - }).save() - // This form should not be fetched (not collab or admin) - let form4 = new Form({ - title: 'Test Form3', - emails: currentAdmin.email, - admin: collabAdmin._id, - permissionList: [roles.collaborator('nofetch@test.gov.sg')], - }).save() - - Promise.all([form1, form2, form3, form4]) - .then(() => { - res.json.and.callFake((args) => { - let times = args.map((f) => f.lastModified) - // Should be sorted by last modified in descending order - expect(times).toEqual( - times.sort((a, b) => { - return b - a - }), - ) - // 3 forms to be fetched - expect(args.length).toEqual(3) - done() - }) - Controller.list(req, res) - }) - .catch((err) => { - done(err) - }) - }) - }) - describe('getFeedback', () => { it('should retrieve correct response based on saved FormFeedbacks', (done) => { // Define feedback to be added to MongoMemoryServer db diff --git a/tests/unit/backend/models/form.server.model.spec.ts b/tests/unit/backend/models/form.server.model.spec.ts index fa3beacb7b..da52420f3f 100644 --- a/tests/unit/backend/models/form.server.model.spec.ts +++ b/tests/unit/backend/models/form.server.model.spec.ts @@ -1,5 +1,5 @@ -import { ObjectID } from 'bson' -import { merge, omit } from 'lodash' +import { ObjectId } from 'bson-ext' +import { merge, omit, orderBy, pick } from 'lodash' import mongoose from 'mongoose' import getFormModel, { @@ -7,9 +7,8 @@ import getFormModel, { getEncryptedFormModel, } from 'src/app/models/form.server.model' import { - IAgencySchema, IEncryptedForm, - IUserSchema, + IPopulatedUser, Permission, ResponseMode, Status, @@ -21,7 +20,7 @@ const Form = getFormModel(mongoose) const EncryptedForm = getEncryptedFormModel(mongoose) const EmailForm = getEmailFormModel(mongoose) -const MOCK_ADMIN_OBJ_ID = new ObjectID() +const MOCK_ADMIN_OBJ_ID = new ObjectId() const MOCK_ADMIN_DOMAIN = 'example.com' const MOCK_ADMIN_EMAIL = `test@${MOCK_ADMIN_DOMAIN}` @@ -63,7 +62,7 @@ const FORM_DEFAULTS = { } describe('Form Model', () => { - let preloadedAdmin: IUserSchema, preloadedAgency: IAgencySchema + let populatedAdmin: IPopulatedUser beforeAll(async () => await dbHandler.connect()) beforeEach(async () => { @@ -72,8 +71,7 @@ describe('Form Model', () => { mailDomain: MOCK_ADMIN_DOMAIN, }) - preloadedAdmin = preloaded.user - preloadedAgency = preloaded.agency + populatedAdmin = merge(preloaded.user, { agency: preloaded.agency }) }) afterEach(async () => await dbHandler.clearDatabase()) afterAll(async () => await dbHandler.closeDatabase()) @@ -169,7 +167,7 @@ describe('Form Model', () => { // Remove indeterministic id from actual permission list const actualPermissionList = saved .toObject() - .permissionList!.map((permission: Permission[]) => + .permissionList.map((permission: Permission[]) => omit(permission, '_id'), ) expect(actualPermissionList).toEqual(permissionList) @@ -177,7 +175,7 @@ describe('Form Model', () => { it('should reject when admin id is invalid', async () => { // Arrange - const invalidAdminId = new ObjectID() + const invalidAdminId = new ObjectId() const paramsWithInvalidAdmin = merge({}, MOCK_FORM_PARAMS, { admin: invalidAdminId, }) @@ -355,7 +353,7 @@ describe('Form Model', () => { expect(actualSavedObject).toEqual(expectedObject) // Remove indeterministic id from actual permission list - const actualPermissionList = (saved.toObject() as IEncryptedForm).permissionList!.map( + const actualPermissionList = (saved.toObject() as IEncryptedForm).permissionList?.map( (permission) => omit(permission, '_id'), ) expect(actualPermissionList).toEqual(permissionList) @@ -379,7 +377,7 @@ describe('Form Model', () => { it('should reject when admin id is invalid', async () => { // Arrange - const invalidAdminId = new ObjectID() + const invalidAdminId = new ObjectId() const paramsWithInvalidAdmin = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, { admin: invalidAdminId, }) @@ -596,7 +594,7 @@ describe('Form Model', () => { // Remove indeterministic id from actual permission list const actualPermissionList = saved .toObject() - .permissionList!.map((permission: Permission[]) => + .permissionList.map((permission: Permission[]) => omit(permission, '_id'), ) expect(actualPermissionList).toEqual(permissionList) @@ -633,7 +631,7 @@ describe('Form Model', () => { it('should reject when admin id is invalid', async () => { // Arrange - const invalidAdminId = new ObjectID() + const invalidAdminId = new ObjectId() const paramsWithInvalidAdmin = merge({}, MOCK_EMAIL_FORM_PARAMS, { admin: invalidAdminId, }) @@ -715,7 +713,7 @@ describe('Form Model', () => { describe('deactivateById', () => { it('should correctly deactivate form for valid ID', async () => { const formParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { - admin: preloadedAdmin, + admin: populatedAdmin, status: Status.Public, }) const form = await Form.create(formParams) @@ -726,7 +724,7 @@ describe('Form Model', () => { it('should not deactivate archived form', async () => { const formParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { - admin: preloadedAdmin, + admin: populatedAdmin, status: Status.Archived, }) const form = await Form.create(formParams) @@ -736,7 +734,7 @@ describe('Form Model', () => { }) it('should return null for invalid form ID', async () => { - const returned = await Form.deactivateById(String(new ObjectID())) + const returned = await Form.deactivateById(String(new ObjectId())) expect(returned).toBeNull() }) }) @@ -744,7 +742,7 @@ describe('Form Model', () => { describe('getFullFormById', () => { it('should return null when the formId is invalid', async () => { // Arrange - const invalidFormId = new ObjectID() + const invalidFormId = new ObjectId() // Act const form = await Form.getFullFormById(String(invalidFormId)) @@ -756,13 +754,13 @@ describe('Form Model', () => { it('should return the populated email form when formId is valid', async () => { // Arrange const emailFormParams = merge({}, MOCK_EMAIL_FORM_PARAMS, { - admin: preloadedAdmin, + admin: populatedAdmin, }) // Create a form const form = (await Form.create(emailFormParams)).toObject() // Act - const actualForm = (await Form.getFullFormById(form._id))!.toObject() + const actualForm = (await Form.getFullFormById(form._id))?.toObject() // Assert // Form should be returned @@ -771,9 +769,9 @@ describe('Form Model', () => { expect(omit(actualForm, 'admin')).toEqual(omit(form, 'admin')) // Verify populated admin shape expect(actualForm.admin).not.toBeNull() - expect(actualForm.admin.email).toEqual(preloadedAdmin.email) + expect(actualForm.admin.email).toEqual(populatedAdmin.email) // Remove indeterministic keys - const expectedAgency = omit(preloadedAgency.toObject(), [ + const expectedAgency = omit(populatedAdmin.agency.toObject(), [ '_id', 'created', 'lastModified', @@ -787,13 +785,13 @@ describe('Form Model', () => { it('should return the populated encrypt form when formId is valid', async () => { // Arrange const encryptFormParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, { - admin: preloadedAdmin, + admin: populatedAdmin, }) // Create a form const form = (await Form.create(encryptFormParams)).toObject() // Act - const actualForm = (await Form.getFullFormById(form._id))!.toObject() + const actualForm = (await Form.getFullFormById(form._id))?.toObject() // Assert // Form should be returned @@ -802,9 +800,9 @@ describe('Form Model', () => { expect(omit(actualForm, 'admin')).toEqual(omit(form, 'admin')) // Verify populated admin shape expect(actualForm.admin).not.toBeNull() - expect(actualForm.admin.email).toEqual(preloadedAdmin.email) + expect(actualForm.admin.email).toEqual(populatedAdmin.email) // Remove indeterministic keys - const expectedAgency = omit(preloadedAgency.toObject(), [ + const expectedAgency = omit(populatedAdmin.agency.toObject(), [ '_id', 'created', 'lastModified', @@ -819,7 +817,7 @@ describe('Form Model', () => { describe('getOtpData', () => { it('should return null when formId does not exist', async () => { // Arrange - const invalidFormId = new ObjectID() + const invalidFormId = new ObjectId() // Act const form = await Form.getOtpData(String(invalidFormId)) @@ -846,8 +844,8 @@ describe('Form Model', () => { const expectedOtpData = { form: form._id, formAdmin: { - email: preloadedAdmin.email, - userId: preloadedAdmin._id, + email: populatedAdmin.email, + userId: populatedAdmin._id, }, msgSrvcName: emailFormParams.msgSrvcName, } @@ -872,8 +870,8 @@ describe('Form Model', () => { const expectedOtpData = { form: form._id, formAdmin: { - email: preloadedAdmin.email, - userId: preloadedAdmin._id, + email: populatedAdmin.email, + userId: populatedAdmin._id, }, msgSrvcName: encryptFormParams.msgSrvcName, } @@ -881,4 +879,100 @@ describe('Form Model', () => { }) }) }) + + describe('getDashboardForms', () => { + it('should return empty array when user has no forms to view', async () => { + // Arrange + const randomUserId = new ObjectId() + const invalidEmail = 'not-valid@example.com' + + // Act + const actual = await Form.getDashboardForms(randomUserId, invalidEmail) + + // Assert + expect(actual).toEqual([]) + }) + + it('should return array of forms user is permitted to view', async () => { + // Arrange + // Add additional user. + const differentUserId = new ObjectId() + const diffPreload = await dbHandler.insertFormCollectionReqs({ + userId: differentUserId, + mailName: 'something-else', + mailDomain: MOCK_ADMIN_DOMAIN, + }) + const diffPopulatedAdmin = merge(diffPreload.user, { + agency: diffPreload.agency, + }) + // Populate multiple forms with different permissions. + // Is admin. + const userOwnedForm = await Form.create(MOCK_EMAIL_FORM_PARAMS) + // Has write permissions. + const userWritePermissionForm = await Form.create({ + ...MOCK_ENCRYPTED_FORM_PARAMS, + admin: diffPopulatedAdmin._id, + permissionList: [{ email: populatedAdmin.email, write: true }], + }) + // Has read permissions. + const userReadPermissionForm = await Form.create({ + ...MOCK_ENCRYPTED_FORM_PARAMS, + admin: diffPopulatedAdmin._id, + // Only read permissions, no write permission. + permissionList: [{ email: populatedAdmin.email, write: false }], + }) + // Should not be fetched since form is archived. + await Form.create({ + ...MOCK_ENCRYPTED_FORM_PARAMS, + status: Status.Archived, + }) + // Should not be fetched (not collab or admin). + await Form.create({ + ...MOCK_ENCRYPTED_FORM_PARAMS, + admin: differentUserId, + // currentUser does not have permissions. + }) + + // Act + const actual = await Form.getDashboardForms( + populatedAdmin._id, + populatedAdmin.email, + ) + + // Assert + // Coerce into expected shape. + const keysToPick = [ + '_id', + 'title', + 'lastModified', + 'status', + 'form_fields', + 'responseMode', + ] + const expected = orderBy( + [ + // Should return form with admin themselves. + merge(pick(userOwnedForm.toObject(), keysToPick), { + admin: populatedAdmin.toObject(), + }), + // Should return form where admin has write permission. + merge(pick(userWritePermissionForm.toObject(), keysToPick), { + admin: diffPopulatedAdmin.toObject(), + }), + // Should return form where admin has read permission. + merge(pick(userReadPermissionForm.toObject(), keysToPick), { + admin: diffPopulatedAdmin.toObject(), + }), + ], + 'lastModified', + 'desc', + ) + // Should return list containing only three forms: + // (admin, read perms, write perms), + // even though there are 5 forms in the collection. + await expect(Form.countDocuments()).resolves.toEqual(5) + expect(actual.length).toEqual(3) + expect(actual).toEqual(expected) + }) + }) })