diff --git a/src/app/controllers/core.server.controller.js b/src/app/controllers/core.server.controller.js index 57193d91ea..ec95c06df6 100755 --- a/src/app/controllers/core.server.controller.js +++ b/src/app/controllers/core.server.controller.js @@ -1,21 +1,3 @@ -const mongoose = require('mongoose') -const _ = require('lodash') -const { StatusCodes } = require('http-status-codes') - -const config = require('../../config/config') -const { getRequestIp, getTrace } = require('../utils/request') -const logger = require('../../config/logger').createLoggerWithLabel(module) - -const getFormStatisticsTotalModel = require('../models/form_statistics_total.server.model') - .default -const getSubmissionModel = require('../models/submission.server.model').default -const getUserModel = require('../models/user.server.model').default - -const FormStatisticsTotal = getFormStatisticsTotalModel(mongoose) -const Submission = getSubmissionModel(mongoose) - -const MIN_SUB_COUNT = 10 // minimum number of submissions before search is returned - /** * Renders root: '/' * @param {Object} req - Express request object @@ -26,141 +8,3 @@ exports.index = function (req, res) { user: JSON.stringify(req.session.user) || 'null', }) } - -/** - * Returns # forms that have > 10 responses using aggregate collection - * @param {Object} req - Express request object - * @param {Object} res - Express response object - */ -exports.formCountUsingAggregateCollection = (req, res) => { - FormStatisticsTotal.aggregate( - [ - { - $match: { - totalCount: { - $gt: MIN_SUB_COUNT, - }, - }, - }, - { - $count: 'numActiveForms', - }, - ], - function (err, [result]) { - if (err) { - logger.error({ - message: 'Mongo form statistics aggregate error', - meta: { - action: 'formCountUsingAggregateCollection', - ip: getRequestIp(req), - trace: getTrace(req), - url: req.url, - headers: req.headers, - }, - error: err, - }) - res.sendStatus(StatusCodes.SERVICE_UNAVAILABLE) - } else if (result) { - res.json(_.get(result, 'numActiveForms', 0)) - } else { - res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR) - } - }, - ) -} - -/** - * Returns # forms that have > 10 responses using submissions collection - * @param {Object} req - Express request object - * @param {Object} res - Express response object - */ -exports.formCountUsingSubmissionsCollection = (req, res) => { - Submission.aggregate( - [ - { - $group: { - _id: '$form', - count: { $sum: 1 }, - }, - }, - { - $match: { - count: { - $gt: MIN_SUB_COUNT, - }, - }, - }, - ], - function (err, forms) { - if (err) { - logger.error({ - message: 'Mongo submission aggregate error', - meta: { - action: 'formCountUsingSubmissionsCollection', - ip: getRequestIp(req), - trace: getTrace(req), - url: req.url, - headers: req.headers, - }, - error: err, - }) - res.sendStatus(StatusCodes.SERVICE_UNAVAILABLE) - } else { - res.json(forms.length) - } - }, - ) -} - -/** - * Returns total number of users - * @param {Object} req - Express request object - * @param {Object} res - Express response object - */ -exports.userCount = (req, res) => { - let User = getUserModel(mongoose) - User.estimatedDocumentCount(function (err, ct) { - if (err) { - logger.error({ - message: 'Mongo user count error', - meta: { - action: 'userCount', - ip: getRequestIp(req), - trace: getTrace(req), - url: req.url, - headers: req.headers, - }, - error: err, - }) - } else { - res.json(ct) - } - }) -} - -/** - * Returns total number of form submissions - * @param {Object} req - Express request object - * @param {Object} res - Express response object - */ -exports.submissionCount = (req, res) => { - let Submission = getSubmissionModel(mongoose) - Submission.estimatedDocumentCount(function (err, ct) { - if (err) { - logger.error({ - message: 'Mongo submission count error', - meta: { - action: 'submissionCount', - ip: getRequestIp(req), - trace: getTrace(req), - url: req.url, - headers: req.headers, - }, - error: err, - }) - } else { - let totalCount = ct + config.submissionsTopUp - res.json(totalCount) - } - }) -} diff --git a/src/app/factories/aggregate-stats.factory.js b/src/app/factories/aggregate-stats.factory.js index 4c8197290f..14b3112611 100644 --- a/src/app/factories/aggregate-stats.factory.js +++ b/src/app/factories/aggregate-stats.factory.js @@ -1,5 +1,4 @@ const featureManager = require('../../config/feature-manager').default -const core = require('../controllers/core.server.controller') const adminConsole = require('../controllers/admin-console.server.controller') const aggregStatsFactory = ({ isEnabled }) => { @@ -8,14 +7,12 @@ const aggregStatsFactory = ({ isEnabled }) => { getExampleForms: adminConsole.getExampleFormsUsingAggregateCollection, getSingleExampleForm: adminConsole.getSingleExampleFormUsingAggregateCollection, - formCount: core.formCountUsingAggregateCollection, } } else { return { getExampleForms: adminConsole.getExampleFormsUsingSubmissionsCollection, getSingleExampleForm: adminConsole.getSingleExampleFormUsingSubmissionCollection, - formCount: core.formCountUsingSubmissionsCollection, } } } diff --git a/src/app/models/form_statistics_total.server.model.ts b/src/app/models/form_statistics_total.server.model.ts index d16081e965..0c14b7a0d1 100644 --- a/src/app/models/form_statistics_total.server.model.ts +++ b/src/app/models/form_statistics_total.server.model.ts @@ -1,15 +1,24 @@ -import { Model, Mongoose, Schema } from 'mongoose' +import { Mongoose, Schema } from 'mongoose' -import { IFormStatisticsTotalSchema } from '../../types' +import { + AggregateFormCountResult, + IFormStatisticsTotalModel, + IFormStatisticsTotalSchema, +} from '../../types' + +import { FORM_SCHEMA_ID } from './form.server.model' const FORM_STATS_TOTAL_SCHEMA_ID = 'FormStatisticsTotal' const FORM_STATS_COLLECTION_NAME = 'formStatisticsTotal' -type IFormStatisticsTotalModel = Model - const compileFormStatisticsTotalModel = (db: Mongoose) => { const FormStatisticsTotalSchema = new Schema( { + formId: { + type: Schema.Types.ObjectId, + ref: FORM_SCHEMA_ID, + required: true, + }, totalCount: { type: Number, required: true, @@ -37,6 +46,25 @@ const compileFormStatisticsTotalModel = (db: Mongoose) => { }, ) + // Static functions + FormStatisticsTotalSchema.statics.aggregateFormCount = function ( + this: IFormStatisticsTotalModel, + minSubCount: number, + ): Promise { + return this.aggregate([ + { + $match: { + totalCount: { + $gt: minSubCount, + }, + }, + }, + { + $count: 'numActiveForms', + }, + ]).exec() + } + const FormStatisticsTotalModel = db.model< IFormStatisticsTotalSchema, IFormStatisticsTotalModel @@ -55,7 +83,9 @@ const compileFormStatisticsTotalModel = (db: Mongoose) => { * @param db The mongoose instance to retrieve the FormStatisticsTotal model from * @returns The FormStatisticsTotal model */ -const getFormStatisticsTotalModel = (db: Mongoose) => { +const getFormStatisticsTotalModel = ( + db: Mongoose, +): IFormStatisticsTotalModel => { try { return db.model(FORM_STATS_TOTAL_SCHEMA_ID) as IFormStatisticsTotalModel } catch { diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts index 1ce4759c8c..614cd70464 100644 --- a/src/app/models/submission.server.model.ts +++ b/src/app/models/submission.server.model.ts @@ -1,11 +1,13 @@ -import { Model, Mongoose, Schema } from 'mongoose' +import { Mongoose, Schema } from 'mongoose' import { AuthType, + FindFormsWithSubsAboveResult, IEmailSubmissionModel, IEmailSubmissionSchema, IEncryptedSubmissionSchema, IEncryptSubmissionModel, + ISubmissionModel, ISubmissionSchema, IWebhookResponseSchema, MyInfoAttribute, @@ -56,9 +58,29 @@ SubmissionSchema.index({ created: -1, }) -// Instance methods +// Base schema static methods +SubmissionSchema.statics.findFormsWithSubsAbove = function ( + this: ISubmissionModel, + minSubCount: number, +): Promise { + return this.aggregate([ + { + $group: { + _id: '$form', + count: { $sum: 1 }, + }, + }, + { + $match: { + count: { + $gt: minSubCount, + }, + }, + }, + ]).exec() +} -const emailSubmissionSchema = new Schema({ +const EmailSubmissionSchema = new Schema({ recipientEmails: { type: [ { @@ -83,10 +105,11 @@ const emailSubmissionSchema = new Schema({ }, }) +// EmailSubmission Instance methods /** * Returns null as email submission does not have a webhook view */ -emailSubmissionSchema.methods.getWebhookView = function (): null { +EmailSubmissionSchema.methods.getWebhookView = function (): null { return null } @@ -110,7 +133,7 @@ const webhookResponseSchema = new Schema( }, ) -const encryptSubmissionSchema = new Schema({ +const EncryptSubmissionSchema = new Schema({ encryptedContent: { type: String, trim: true, @@ -135,7 +158,7 @@ const encryptSubmissionSchema = new Schema({ * Returns an object which represents the encrypted submission * which will be posted to the webhook URL. */ -encryptSubmissionSchema.methods.getWebhookView = function ( +EncryptSubmissionSchema.methods.getWebhookView = function ( this: IEncryptedSubmissionSchema, ): WebhookView { const webhookData: WebhookData = { @@ -152,27 +175,34 @@ encryptSubmissionSchema.methods.getWebhookView = function ( } } -const compileSubmissionModel = (db: Mongoose) => { +const compileSubmissionModel = (db: Mongoose): ISubmissionModel => { const Submission = db.model('Submission', SubmissionSchema) - Submission.discriminator(SubmissionType.Email, emailSubmissionSchema) - Submission.discriminator(SubmissionType.Encrypt, encryptSubmissionSchema) - return db.model(SUBMISSION_SCHEMA_ID, SubmissionSchema) + Submission.discriminator(SubmissionType.Email, EmailSubmissionSchema) + Submission.discriminator(SubmissionType.Encrypt, EncryptSubmissionSchema) + return db.model( + SUBMISSION_SCHEMA_ID, + SubmissionSchema, + ) as ISubmissionModel } -const getSubmissionModel = (db: Mongoose) => { +const getSubmissionModel = (db: Mongoose): ISubmissionModel => { try { - return db.model(SUBMISSION_SCHEMA_ID) as Model + return db.model(SUBMISSION_SCHEMA_ID) as ISubmissionModel } catch { return compileSubmissionModel(db) } } -export const getEmailSubmissionModel = (db: Mongoose) => { +export const getEmailSubmissionModel = ( + db: Mongoose, +): IEmailSubmissionModel => { getSubmissionModel(db) return db.model(SubmissionType.Email) as IEmailSubmissionModel } -export const getEncryptSubmissionModel = (db: Mongoose) => { +export const getEncryptSubmissionModel = ( + db: Mongoose, +): IEncryptSubmissionModel => { getSubmissionModel(db) return db.model(SubmissionType.Encrypt) as IEncryptSubmissionModel } diff --git a/src/app/modules/analytics/__tests__/analytics.controller.spec.ts b/src/app/modules/analytics/__tests__/analytics.controller.spec.ts new file mode 100644 index 0000000000..b0319857d9 --- /dev/null +++ b/src/app/modules/analytics/__tests__/analytics.controller.spec.ts @@ -0,0 +1,130 @@ +import { DatabaseError } from 'dist/backend/app/modules/core/core.errors' +import { errAsync, okAsync } from 'neverthrow' +import expressHandler from 'tests/unit/backend/helpers/jest-express' + +import * as AnalyticsController from '../analytics.controller' +import { AnalyticsFactory } from '../analytics.factory' +import * as AnalyticsService from '../analytics.service' + +describe('analytics.controller', () => { + const MOCK_REQ = expressHandler.mockRequest() + afterEach(() => jest.clearAllMocks()) + + describe('handleGetUserCount', () => { + it('should return 200 with number of users on success', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + const mockUserCount = 21 + const getUserSpy = jest + .spyOn(AnalyticsService, 'getUserCount') + .mockReturnValueOnce(okAsync(mockUserCount)) + + // Act + await AnalyticsController.handleGetUserCount(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(getUserSpy).toHaveBeenCalledTimes(1) + expect(mockRes.status).not.toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith(mockUserCount) + }) + + it('should return 500 when error occurs whilst retrieving user count', async () => { + const mockRes = expressHandler.mockResponse() + const getUserSpy = jest + .spyOn(AnalyticsService, 'getUserCount') + .mockReturnValueOnce(errAsync(new DatabaseError())) + + // Act + await AnalyticsController.handleGetUserCount(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(getUserSpy).toHaveBeenCalledTimes(1) + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith( + 'Unable to retrieve number of users from the database', + ) + }) + }) + + describe('handleGetSubmissionCount', () => { + it('should return 200 with number of submissions on success', async () => { + // Arrange + const mockSubmissionCount = 1234 + const mockRes = expressHandler.mockResponse() + const getSubsSpy = jest + .spyOn(AnalyticsService, 'getSubmissionCount') + .mockReturnValueOnce(okAsync(mockSubmissionCount)) + + // Act + await AnalyticsController.handleGetSubmissionCount( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(getSubsSpy).toHaveBeenCalledTimes(1) + expect(mockRes.status).not.toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith(mockSubmissionCount) + }) + + it('should return 500 when error occurs whilst retrieving submission count', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + const getSubsSpy = jest + .spyOn(AnalyticsService, 'getSubmissionCount') + .mockReturnValueOnce(errAsync(new DatabaseError())) + + // Act + await AnalyticsController.handleGetSubmissionCount( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(getSubsSpy).toHaveBeenCalledTimes(1) + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith( + 'Unable to retrieve number of submissions from the database', + ) + }) + }) + + describe('handleGetFormCount', () => { + it('should return 200 with number of forms on success', async () => { + // Arrange + const mockFormCount = 99543 + const mockRes = expressHandler.mockResponse() + const getFormSpy = jest + .spyOn(AnalyticsFactory, 'getFormCount') + .mockReturnValueOnce(okAsync(mockFormCount)) + + // Act + await AnalyticsController.handleGetFormCount(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(getFormSpy).toHaveBeenCalledTimes(1) + expect(mockRes.status).not.toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith(mockFormCount) + }) + + it('should return 500 when error occurs whilst retrieving form count', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + const getFormSpy = jest + .spyOn(AnalyticsFactory, 'getFormCount') + .mockReturnValueOnce(errAsync(new DatabaseError())) + + // Act + await AnalyticsController.handleGetFormCount(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(getFormSpy).toHaveBeenCalledTimes(1) + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith( + 'Unable to retrieve number of forms from the database', + ) + }) + }) +}) diff --git a/src/app/modules/analytics/__tests__/analytics.factory.spec.ts b/src/app/modules/analytics/__tests__/analytics.factory.spec.ts new file mode 100644 index 0000000000..53a4a6de9e --- /dev/null +++ b/src/app/modules/analytics/__tests__/analytics.factory.spec.ts @@ -0,0 +1,68 @@ +import { okAsync } from 'neverthrow' +import { mocked } from 'ts-jest/utils' + +import { + FeatureNames, + IAggregateStats, + RegisteredFeature, +} from 'src/config/feature-manager' + +import { createAnalyticsFactory } from '../analytics.factory' +import * as AnalyticsService from '../analytics.service' + +jest.mock('../analytics.service') +const MockAnalyticsService = mocked(AnalyticsService) + +describe('analytics.factory', () => { + describe('aggregate-stats feature is enabled', () => { + const MOCK_ENABLED_FEATURE: RegisteredFeature = { + isEnabled: true, + props: {} as IAggregateStats, + } + const AnalyticsFactory = createAnalyticsFactory(MOCK_ENABLED_FEATURE) + + describe('getFormCount', () => { + it('should invoke AnalyticsService#getFormCountWithStatsCollection', async () => { + // Arrange + const mockFormCount = 200 + const serviceStatsSpy = MockAnalyticsService.getFormCountWithStatsCollection.mockReturnValue( + okAsync(mockFormCount), + ) + + // Act + const actualResults = await AnalyticsFactory.getFormCount() + + // Assert + expect(serviceStatsSpy).toHaveBeenCalledTimes(1) + expect(actualResults.isOk()).toEqual(true) + expect(actualResults._unsafeUnwrap()).toEqual(mockFormCount) + }) + }) + }) + + describe('aggregate-stats feature is disabled', () => { + const MOCK_DISABLED_FEATURE: RegisteredFeature = { + isEnabled: false, + props: {} as IAggregateStats, + } + const AnalyticsFactory = createAnalyticsFactory(MOCK_DISABLED_FEATURE) + + describe('getFormCount', () => { + it('should invoke AnalyticsService#getFormCountWithSubmissionCollection', async () => { + // Arrange + const mockFormCount = 1 + const serviceSubmissionSpy = MockAnalyticsService.getFormCountWithSubmissionCollection.mockReturnValue( + okAsync(mockFormCount), + ) + + // Act + const actualResults = await AnalyticsFactory.getFormCount() + + // Assert + expect(serviceSubmissionSpy).toHaveBeenCalledTimes(1) + expect(actualResults.isOk()).toEqual(true) + expect(actualResults._unsafeUnwrap()).toEqual(mockFormCount) + }) + }) + }) +}) diff --git a/src/app/modules/analytics/__tests__/analytics.service.spec.ts b/src/app/modules/analytics/__tests__/analytics.service.spec.ts new file mode 100644 index 0000000000..a71c333028 --- /dev/null +++ b/src/app/modules/analytics/__tests__/analytics.service.spec.ts @@ -0,0 +1,295 @@ +import { times } from 'lodash' +import mongoose, { Query } from 'mongoose' +import dbHandler from 'tests/unit/backend/helpers/jest-db' + +import getFormStatisticsTotalModel from 'src/app/models/form_statistics_total.server.model' +import getSubmissionModel from 'src/app/models/submission.server.model' +import getUserModel from 'src/app/models/user.server.model' +import { + IAgencySchema, + IFormStatisticsTotalSchema, + ISubmissionSchema, + SubmissionType, +} from 'src/types' + +import { DatabaseError } from '../../core/core.errors' +import { MIN_SUB_COUNT } from '../analytics.constants' +import { + getFormCountWithStatsCollection, + getFormCountWithSubmissionCollection, + getSubmissionCount, + getUserCount, +} from '../analytics.service' + +const FormStatsModel = getFormStatisticsTotalModel(mongoose) +const SubmissionModel = getSubmissionModel(mongoose) +const UserModel = getUserModel(mongoose) + +describe('analytics.service', () => { + beforeAll(async () => await dbHandler.connect()) + beforeEach(async () => { + await dbHandler.clearDatabase() + jest.restoreAllMocks() + }) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('getFormCountWithStatsCollection', () => { + it('should return the number of forms with more than 10 submissions when such forms exists', async () => { + // Arrange + // Number of submissions per form + const formCounts = [12, 10, 4] + const submissionPromises: Promise[] = [] + formCounts.forEach((count) => { + submissionPromises.push( + FormStatsModel.create({ + formId: mongoose.Types.ObjectId(), + totalCount: count, + lastSubmission: new Date(), + }), + ) + }) + await Promise.all(submissionPromises) + + // Act + const actualResult = await getFormCountWithStatsCollection() + + // Assert + const expectedResult = formCounts.filter((fc) => fc > MIN_SUB_COUNT) + .length + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(expectedResult) + }) + + it('should return 0 when no forms have above 10 submissions', async () => { + // Arrange + // Number of submissions per form + const formCounts = [1, 2] + const submissionPromises: Promise[] = [] + formCounts.forEach((count) => { + submissionPromises.push( + FormStatsModel.create({ + formId: mongoose.Types.ObjectId(), + totalCount: count, + lastSubmission: new Date(), + }), + ) + }) + await Promise.all(submissionPromises) + + // Act + const actualResult = await getFormCountWithStatsCollection() + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(0) + }) + + it('should return DatabaseError when error occurs whilst querying database', async () => { + // Arrange + const aggregateSpy = jest + .spyOn(FormStatsModel, 'aggregateFormCount') + .mockRejectedValueOnce(new Error('some error')) + + // Act + const actualResult = await getFormCountWithStatsCollection() + + // Assert + expect(aggregateSpy).toHaveBeenCalled() + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toEqual(new DatabaseError()) + }) + }) + + describe('getFormCountWithSubmissionCollection', () => { + it('should return the number of forms with more than 10 submissions when such forms exists', async () => { + // Arrange + const formCounts = [12, 10, 4] + const submissionPromises: Promise[] = [] + formCounts.forEach((count) => { + const formId = mongoose.Types.ObjectId() + times(count, () => + submissionPromises.push( + SubmissionModel.create({ + form: formId, + myInfoFields: [], + submissionType: SubmissionType.Email, + responseHash: 'hash', + responseSalt: 'salt', + }), + ), + ) + }) + await Promise.all(submissionPromises) + + // Act + const actualResult = await getFormCountWithSubmissionCollection() + + // Assert + const expectedResult = formCounts.filter((fc) => fc > MIN_SUB_COUNT) + .length + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(expectedResult) + }) + + it('should return 0 when no forms have above 10 submissions', async () => { + // Arrange + const formCounts = [1, 2] + const submissionPromises: Promise[] = [] + formCounts.forEach((count) => { + const formId = mongoose.Types.ObjectId() + times(count, () => + submissionPromises.push( + SubmissionModel.create({ + form: formId, + myInfoFields: [], + submissionType: SubmissionType.Email, + responseHash: 'hash', + responseSalt: 'salt', + }), + ), + ) + }) + await Promise.all(submissionPromises) + + // Act + const actualResult = await getFormCountWithSubmissionCollection() + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(0) + }) + + it('should return DatabaseError when error occurs whilst querying database', async () => { + // Arrange + const aggregateSpy = jest + .spyOn(SubmissionModel, 'findFormsWithSubsAbove') + .mockRejectedValueOnce(new Error('some error')) + + // Act + const actualResult = await getFormCountWithSubmissionCollection() + + // Assert + expect(aggregateSpy).toHaveBeenCalled() + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toEqual(new DatabaseError()) + }) + }) + + describe('getUserCount', () => { + const VALID_DOMAIN = 'example.com' + let testAgency: IAgencySchema + + beforeEach(async () => { + testAgency = await dbHandler.insertDefaultAgency({ + mailDomain: VALID_DOMAIN, + }) + }) + + it('should return 0 when there are no users in the database', async () => { + // Arrange + const initialUserCount = await UserModel.estimatedDocumentCount() + expect(initialUserCount).toEqual(0) + + // Act + const actualResult = await getUserCount() + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(0) + }) + + it('should return number of users in the database', async () => { + // Arrange + const expectedNumUsers = 10 + const userPromises = times(expectedNumUsers, () => + UserModel.create({ + agency: testAgency._id, + email: `${Math.random()}@${VALID_DOMAIN}`, + }), + ) + await Promise.all(userPromises) + const initialUserCount = await UserModel.estimatedDocumentCount() + expect(initialUserCount).toEqual(expectedNumUsers) + + // Act + const actualResult = await getUserCount() + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(expectedNumUsers) + }) + + it('should return DatabaseError when error occurs whilst retrieving user count', async () => { + // Arrange + const execSpy = jest.fn().mockRejectedValueOnce(new Error('boom')) + jest.spyOn(UserModel, 'estimatedDocumentCount').mockReturnValueOnce(({ + exec: execSpy, + } as unknown) as Query) + + // Act + const actualResult = await getUserCount() + + // Assert + expect(execSpy).toHaveBeenCalledTimes(1) + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toEqual(new DatabaseError()) + }) + }) + + describe('getSubmissionCount', () => { + it('should return 0 when there are no submissions in the database', async () => { + // Arrange + const initialSubCount = await SubmissionModel.estimatedDocumentCount() + expect(initialSubCount).toEqual(0) + + // Act + const actualResult = await getSubmissionCount() + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(0) + }) + + it('should return number of submissions in the database', async () => { + // Arrange + const expectedNumSubs = 10 + const submissionPromises = times(expectedNumSubs, () => + SubmissionModel.create({ + form: mongoose.Types.ObjectId(), + myInfoFields: [], + submissionType: SubmissionType.Email, + responseHash: 'hash', + responseSalt: 'salt', + }), + ) + await Promise.all(submissionPromises) + const initialUserCount = await SubmissionModel.estimatedDocumentCount() + expect(initialUserCount).toEqual(expectedNumSubs) + + // Act + const actualResult = await getSubmissionCount() + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(expectedNumSubs) + }) + + it('should return DatabaseError when error occurs whilst retrieving submission count', async () => { + // Arrange + const execSpy = jest.fn().mockRejectedValueOnce(new Error('boom')) + jest + .spyOn(SubmissionModel, 'estimatedDocumentCount') + .mockReturnValueOnce(({ + exec: execSpy, + } as unknown) as Query) + + // Act + const actualResult = await getSubmissionCount() + + // Assert + expect(execSpy).toHaveBeenCalledTimes(1) + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toEqual(new DatabaseError()) + }) + }) +}) diff --git a/src/app/modules/analytics/analytics.constants.ts b/src/app/modules/analytics/analytics.constants.ts new file mode 100644 index 0000000000..6d7e0de435 --- /dev/null +++ b/src/app/modules/analytics/analytics.constants.ts @@ -0,0 +1,4 @@ +/** + * Minimum number of submissions before search form is returned + */ +export const MIN_SUB_COUNT = 10 diff --git a/src/app/modules/analytics/analytics.controller.ts b/src/app/modules/analytics/analytics.controller.ts new file mode 100644 index 0000000000..5caec017f1 --- /dev/null +++ b/src/app/modules/analytics/analytics.controller.ts @@ -0,0 +1,104 @@ +import { RequestHandler } from 'express' +import { StatusCodes } from 'http-status-codes' + +import { submissionsTopUp } from '../../../config/config' +import { createLoggerWithLabel } from '../../../config/logger' +import { getRequestIp, getTrace } from '../../utils/request' + +import { AnalyticsFactory } from './analytics.factory' +import { getSubmissionCount, getUserCount } from './analytics.service' + +const logger = createLoggerWithLabel(module) + +/** + * Handler for GET /analytics/users + * @route GET /analytics/users + * @returns 200 with the number of users building forms + * @returns 500 when database error occurs whilst retrieving user count + */ +export const handleGetUserCount: RequestHandler = async (req, res) => { + const countResult = await getUserCount() + + if (countResult.isErr()) { + logger.error({ + message: 'Mongo user count error', + meta: { + action: 'handleGetUserCount', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + trace: getTrace(req), + }, + error: countResult.error, + }) + + return res + .status(StatusCodes.INTERNAL_SERVER_ERROR) + .json('Unable to retrieve number of users from the database') + } + + return res.json(countResult.value) +} + +/** + * Handler for GET /analytics/submissions + * @route GET /analytics/submissions + * @returns 200 with the number of submissions across forms + * @returns 500 when database error occurs whilst retrieving submissions count + */ +export const handleGetSubmissionCount: RequestHandler = async (req, res) => { + const countResult = await getSubmissionCount() + + if (countResult.isErr()) { + logger.error({ + message: 'Mongo submissions count error', + meta: { + action: 'handleGetSubmissionCount', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + trace: getTrace(req), + }, + error: countResult.error, + }) + + return res + .status(StatusCodes.INTERNAL_SERVER_ERROR) + .json('Unable to retrieve number of submissions from the database') + } + + // Top up submissions from config file that tracks submissions that has been + // archived (and thus deleted from the database). + const totalProperCount = countResult.value + submissionsTopUp + return res.json(totalProperCount) +} + +/** + * Handler for GET /analytics/forms + * @route GET /analytics/forms + * @returns 200 with the number of popular forms on the application + * @returns 500 when database error occurs whilst retrieving form count + */ +export const handleGetFormCount: RequestHandler = async (req, res) => { + const countResult = await AnalyticsFactory.getFormCount() + + if (countResult.isErr()) { + logger.error({ + message: 'Mongo form count error', + meta: { + action: 'handleGetFormCount', + ip: getRequestIp(req), + url: req.url, + headers: req.headers, + trace: getTrace(req), + }, + error: countResult.error, + }) + + return res + .status(StatusCodes.INTERNAL_SERVER_ERROR) + .json('Unable to retrieve number of forms from the database') + } + + return res.json(countResult.value) +} diff --git a/src/app/modules/analytics/analytics.factory.ts b/src/app/modules/analytics/analytics.factory.ts new file mode 100644 index 0000000000..651adc8142 --- /dev/null +++ b/src/app/modules/analytics/analytics.factory.ts @@ -0,0 +1,37 @@ +import { ResultAsync } from 'neverthrow' + +import FeatureManager, { + FeatureNames, + RegisteredFeature, +} from '../../../config/feature-manager' +import { DatabaseError } from '../core/core.errors' + +import { + getFormCountWithStatsCollection, + getFormCountWithSubmissionCollection, +} from './analytics.service' + +interface IAnalyticsFactory { + getFormCount: () => ResultAsync +} + +const aggregateFeature = FeatureManager.get(FeatureNames.AggregateStats) + +// Exported for testing. +export const createAnalyticsFactory = ({ + isEnabled, + props, +}: RegisteredFeature): IAnalyticsFactory => { + if (isEnabled && props) { + return { + getFormCount: getFormCountWithStatsCollection, + } + } + + // Not enabled, return retrieve forms with submissions collection + return { + getFormCount: getFormCountWithSubmissionCollection, + } +} + +export const AnalyticsFactory = createAnalyticsFactory(aggregateFeature) diff --git a/src/app/modules/analytics/analytics.routes.ts b/src/app/modules/analytics/analytics.routes.ts new file mode 100644 index 0000000000..cfebdc7b40 --- /dev/null +++ b/src/app/modules/analytics/analytics.routes.ts @@ -0,0 +1,33 @@ +import { Router } from 'express' + +import * as AnalyticsController from './analytics.controller' + +export const AnalyticsRouter = Router() + +/** + * Retrieves the number of popular forms on the application + * @route GET /analytics/forms + * @group analytics - form usage statistics + * @returns 200 with the number of forms with more than 10 submissions + */ +AnalyticsRouter.get('/forms', AnalyticsController.handleGetFormCount) + +/** + * Retrieves the number of users building forms on the application. + * @route GET /analytics/users + * @group analytics - form usage statistics + * @returns 200 with the number of users building forms + * @returns 500 when database error occurs whilst retrieving user count + */ +AnalyticsRouter.get('/users', AnalyticsController.handleGetUserCount) + +/** + * Retrieves the total number of submissions of forms across the application. + * @route GET /analytics/submissions + * @group analytics - form usage statistics + * @returns 200 with the total number of submissions of forms + */ +AnalyticsRouter.get( + '/submissions', + AnalyticsController.handleGetSubmissionCount, +) diff --git a/src/app/modules/analytics/analytics.service.ts b/src/app/modules/analytics/analytics.service.ts new file mode 100644 index 0000000000..231fc53a05 --- /dev/null +++ b/src/app/modules/analytics/analytics.service.ts @@ -0,0 +1,125 @@ +import mongoose from 'mongoose' +import { ResultAsync } from 'neverthrow' + +import { createLoggerWithLabel } from '../../../config/logger' +import getFormStatisticsTotalModel from '../../models/form_statistics_total.server.model' +import getSubmissionModel from '../../models/submission.server.model' +import getUserModel from '../../models/user.server.model' +import { DatabaseError } from '../core/core.errors' + +import { MIN_SUB_COUNT } from './analytics.constants' + +const FormStatisticsModel = getFormStatisticsTotalModel(mongoose) +const SubmissionModel = getSubmissionModel(mongoose) +const UserModel = getUserModel(mongoose) +const logger = createLoggerWithLabel(module) + +/** + * Retrieves the number of user documents in the database. + * @returns ok(user count) on success + * @returns err(DatabaseError) on query failure + */ +export const getUserCount = (): ResultAsync => { + return ResultAsync.fromPromise( + UserModel.estimatedDocumentCount().exec(), + (error) => { + logger.error({ + message: 'Database error when retrieving user collection count', + meta: { + action: 'getUserCount', + }, + error, + }) + + return new DatabaseError() + }, + ) +} + +/** + * Retrieves the number of submission documents in the database. + * @returns ok(submissions count) on success + * @returns err(DatabaseError) on query failure + */ +export const getSubmissionCount = (): ResultAsync => { + return ResultAsync.fromPromise( + SubmissionModel.estimatedDocumentCount().exec(), + (error) => { + logger.error({ + message: 'Database error when retrieving submission collection count', + meta: { + action: 'getSubmissionCount', + }, + error, + }) + + return new DatabaseError() + }, + ) +} + +/** + * !!! This function should only be called by {@link AnalyticsFactory} !!! + * + * ! Access to this function should be determined by whether the `aggregate-stats` feature is enabled. + * + * Retrieves the number of forms that has more than MIN_SUB_COUNT responses + * using the form statistics collection. + * @private + * @returns ok(form count) on success + * @returns err(DatabaseError) on query failure + */ +export const getFormCountWithStatsCollection = (): ResultAsync< + number, + DatabaseError +> => { + return ResultAsync.fromPromise( + FormStatisticsModel.aggregateFormCount(MIN_SUB_COUNT), + (error) => { + logger.error({ + message: + 'Database error when retrieving form count from FormStatisticsTotal collection', + meta: { + action: 'getFormCountWithStatsCollection', + }, + error, + }) + + return new DatabaseError() + }, + ).map(([result]) => { + return result?.numActiveForms ?? 0 + }) +} + +/** + * !!! This function should only be called by {@link AnalyticsFactory} !!! + * + * ! Access to this function should be determined by whether the `aggregate-stats` feature is enabled. + * + * Retrieves the number of forms that has more than MIN_SUB_COUNT responses + * using the submissions collection. + * @private + * @returns ok(form count) on success + * @returns err(DatabaseError) on query failure + */ +export const getFormCountWithSubmissionCollection = (): ResultAsync< + number, + DatabaseError +> => { + return ResultAsync.fromPromise( + SubmissionModel.findFormsWithSubsAbove(MIN_SUB_COUNT), + (error) => { + logger.error({ + message: + 'Database error when retrieving form count from submissions collection', + meta: { + action: 'getFormCountWithSubmissionCollection', + }, + error, + }) + + return new DatabaseError() + }, + ).map((forms) => forms.length) +} diff --git a/src/app/routes/core.server.routes.js b/src/app/routes/core.server.routes.js index 2690719b4d..0f7720bc84 100755 --- a/src/app/routes/core.server.routes.js +++ b/src/app/routes/core.server.routes.js @@ -4,36 +4,8 @@ * Module dependencies. */ let core = require('../../app/controllers/core.server.controller') -const aggregStatsFactory = require('../factories/aggregate-stats.factory') module.exports = function (app) { // Core routing app.route('/').get(core.index) - - /** - * Retrieves the number of popular forms on FormSG - * @route GET /analytics/forms - * @group analytics - form usage statistics - * @produces application/json - * @returns {Number} 200 - the number of forms with more than 10 submissions - */ - app.route('/analytics/forms').get(aggregStatsFactory.formCount) - - /** - * Retrieves the number of users building forms on FormSG - * @route GET /analytics/users - * @group analytics - form usage statistics - * @produces application/json - * @returns {Number} 200 - the number of users building forms - */ - app.route('/analytics/users').get(core.userCount) - - /** - * Retrieves the total number of submissions of forms across FormSG - * @route GET /analytics/submissions - * @group analytics - form usage statistics - * @produces application/json - * @returns {Number} 200 - the total number of submissions of forms - */ - app.route('/analytics/submissions').get(core.submissionCount) } diff --git a/src/loaders/express/index.ts b/src/loaders/express/index.ts index a88ee4495c..e7aab1b535 100644 --- a/src/loaders/express/index.ts +++ b/src/loaders/express/index.ts @@ -8,6 +8,7 @@ import nocache from 'nocache' import path from 'path' import url from 'url' +import { AnalyticsRouter } from '../../app/modules/analytics/analytics.routes' import { AuthRouter } from '../../app/modules/auth/auth.routes' import { BounceRouter } from '../../app/modules/bounce/bounce.routes' import UserRouter from '../../app/modules/user/user.routes' @@ -134,6 +135,7 @@ const loadExpressApp = async (connection: Connection) => { app.use('/user', UserRouter) app.use('/emailnotifications', BounceRouter) app.use('/transaction', VfnRouter) + app.use('/analytics', AnalyticsRouter) app.use(sentryMiddlewares()) diff --git a/src/types/form_statistics_total.ts b/src/types/form_statistics_total.ts index 4ca46a9ba9..d0d66dde8a 100644 --- a/src/types/form_statistics_total.ts +++ b/src/types/form_statistics_total.ts @@ -1,6 +1,9 @@ -import { Document } from 'mongoose' +import { Document, Model } from 'mongoose' + +import { IFormSchema } from './form' export interface IFormStatisticsTotal { + formId: IFormSchema['_id'] totalCount: number lastSubmission: Date _id: Document['_id'] @@ -9,3 +12,12 @@ export interface IFormStatisticsTotal { export interface IFormStatisticsTotalSchema extends IFormStatisticsTotal, Document {} + +export type AggregateFormCountResult = + | [{ numActiveForms: number } | undefined] + | never[] + +export interface IFormStatisticsTotalModel + extends Model { + aggregateFormCount(minSubCount: number): Promise +} diff --git a/src/types/submission.ts b/src/types/submission.ts index 70cf5bd3a7..6ac39ce25d 100644 --- a/src/types/submission.ts +++ b/src/types/submission.ts @@ -11,12 +11,12 @@ export enum SubmissionType { export interface ISubmission { form: IFormSchema['_id'] - authType: AuthType - myInfoFields: MyInfoAttribute[] + authType?: AuthType + myInfoFields?: MyInfoAttribute[] submissionType: SubmissionType created?: Date lastModified?: Date - _id: Document['_id'] + _id?: Document['_id'] recipientEmails?: string[] responseHash?: string responseSalt?: string @@ -42,9 +42,21 @@ export interface WebhookView { } export interface ISubmissionSchema extends ISubmission, Document { + _id: Document['_id'] getWebhookView(): WebhookView | null } +export type FindFormsWithSubsAboveResult = { + _id: IFormSchema['_id'] + count: number +} + +export interface ISubmissionModel extends Model { + findFormsWithSubsAbove( + minSubCount: number, + ): Promise +} + export interface IEmailSubmission extends ISubmission { recipientEmails: string[] responseHash: string @@ -85,7 +97,9 @@ export interface IWebhookResponse { } } -export type IEmailSubmissionModel = Model -export type IEncryptSubmissionModel = Model +export type IEmailSubmissionModel = Model & + ISubmissionModel +export type IEncryptSubmissionModel = Model & + ISubmissionModel export interface IWebhookResponseSchema extends IWebhookResponse, Document {} diff --git a/tests/unit/backend/controllers/core.server.controller.spec.js b/tests/unit/backend/controllers/core.server.controller.spec.js index 2d39975bee..d3a2b7f1f5 100644 --- a/tests/unit/backend/controllers/core.server.controller.spec.js +++ b/tests/unit/backend/controllers/core.server.controller.spec.js @@ -1,23 +1,9 @@ const mongoose = require('mongoose') const dbHandler = require('../helpers/db-handler') -const User = dbHandler.makeModel('user.server.model', 'User') -const Agency = dbHandler.makeModel('agency.server.model', 'Agency') -const Submission = dbHandler.makeModel('submission.server.model', 'Submission') -const FormStatisticsTotal = dbHandler.makeModel( - 'form_statistics_total.server.model', - 'FormStatisticsTotal', -) describe('Core Controller', () => { // Declare global variables - - const testAgency = new Agency({ - shortName: 'govtest', - fullName: 'Government Testing Agency', - emailDomain: 'test.gov.sg', - logo: '/invalid-path/test.jpg', - }) const Controller = spec( 'dist/backend/app/controllers/core.server.controller', { @@ -58,122 +44,4 @@ describe('Core Controller', () => { }) }) }) - - describe('formCount', () => { - it('should return the number of forms with more than 10 submissions from form stats collection', (done) => { - // Number of submissions per form - let formCounts = [12, 10, 4, 20] - let submissionPromises = [] - formCounts.forEach((count) => { - let formId = mongoose.Types.ObjectId() - submissionPromises.push( - new FormStatisticsTotal({ - form: formId, - totalCount: count, - lastSubmission: new Date(), - }).save(), - ) - }) - Promise.all(submissionPromises) - .then(() => { - res.json.and.callFake(() => { - expect(res.json).toHaveBeenCalledWith( - _.filter(formCounts, (fc) => fc > 10).length, - ) - done() - }) - Controller.formCountUsingAggregateCollection(req, res) - }) - .catch(done) - }) - - it('should return the number of forms with more than 10 submissions from submissions collection', (done) => { - // Number of submissions per form - let formCounts = [ - { - formId: mongoose.Types.ObjectId(), - count: 12, - }, - { - formId: mongoose.Types.ObjectId(), - count: 10, - }, - { - formId: mongoose.Types.ObjectId(), - count: 4, - }, - ] - let submissionPromises = [] - formCounts.forEach(({ formId, count }) => { - while (count > 0) { - submissionPromises.push( - new Submission({ - form: formId, - myInfoFields: [], - submissionType: 'emailSubmission', - responseHash: 'hash', - responseSalt: 'salt', - }).save(), - ) - count-- - } - }) - Promise.all(submissionPromises) - .then(() => { - res.json.and.callFake(() => { - expect(res.json).toHaveBeenCalledWith( - _.filter(formCounts, (fc) => fc.count > 10).length, - ) - done() - }) - Controller.formCountUsingSubmissionsCollection(req, res) - }) - .catch(done) - }) - }) - - describe('userCount', () => { - it('should return the number of users', (done) => { - // Number of users to populate - let numUsers = 30 - testAgency - .save() - .then(() => { - let userPromises = _.times(numUsers, (time) => - new User({ - email: `user${time}@test.gov.sg`, - agency: mongoose.Types.ObjectId(), - }).save(), - ) - return Promise.all(userPromises) - }) - .then(() => { - res.json.and.callFake(() => { - expect(res.json).toHaveBeenCalledWith(numUsers) - done() - }) - Controller.userCount(req, res) - }) - .catch(done) - }) - }) - - describe('submissionCount', () => { - it('should return the number of submissions', (done) => { - // Number of submissions to populate - let numSubmissions = 300 - let submissionPromises = _.times(numSubmissions, () => - new Submission({ form: mongoose.Types.ObjectId() }).save(), - ) - Promise.all(submissionPromises) - .then(() => { - res.json.and.callFake(() => { - expect(res.json).toHaveBeenCalledWith(numSubmissions) - done() - }) - Controller.submissionCount(req, res) - }) - .catch(done) - }) - }) }) diff --git a/tests/unit/backend/models/form_statistics_total.server.model.spec.ts b/tests/unit/backend/models/form_statistics_total.server.model.spec.ts new file mode 100644 index 0000000000..b3270c14aa --- /dev/null +++ b/tests/unit/backend/models/form_statistics_total.server.model.spec.ts @@ -0,0 +1,74 @@ +import mongoose from 'mongoose' + +import getFormStatisticsTotalModel from 'src/app/models/form_statistics_total.server.model' +import { AggregateFormCountResult, IFormStatisticsTotalSchema } from 'src/types' + +import dbHandler from '../helpers/jest-db' + +const FormStatsModel = getFormStatisticsTotalModel(mongoose) + +describe('FormStatisticsTotal Model', () => { + beforeAll(async () => await dbHandler.connect()) + afterEach(async () => await dbHandler.clearDatabase()) + afterAll(async () => await dbHandler.closeDatabase()) + + describe('Statics', () => { + describe('aggregateFormCount', () => { + it('should return number of active forms that is above the given minimum submission threshold', async () => { + // Number of submissions per form + const formCounts = [12, 10, 4, 20] + const submissionPromises: Promise[] = [] + formCounts.forEach((count) => { + submissionPromises.push( + FormStatsModel.create({ + formId: mongoose.Types.ObjectId(), + totalCount: count, + lastSubmission: new Date(), + }), + ) + }) + await Promise.all(submissionPromises) + const minSubCount = 10 + + // Act + const actualResult = await FormStatsModel.aggregateFormCount( + minSubCount, + ) + + // Assert + const expectedResult: AggregateFormCountResult = [ + { + numActiveForms: formCounts.filter((fc) => fc > minSubCount).length, + }, + ] + expect(actualResult).toEqual(expectedResult) + }) + + it('should return empty array if no documents meet the given submission threshold', async () => { + // Number of submissions per form + const formCounts = [12, 10, 4] + const submissionPromises: Promise[] = [] + formCounts.forEach((count) => { + submissionPromises.push( + FormStatsModel.create({ + formId: mongoose.Types.ObjectId(), + totalCount: count, + lastSubmission: new Date(), + }), + ) + }) + await Promise.all(submissionPromises) + const minSubCount = 12 + + // Act + const actualResult = await FormStatsModel.aggregateFormCount( + minSubCount, + ) + + // Assert + const expectedResult: AggregateFormCountResult = [] + expect(actualResult).toEqual(expectedResult) + }) + }) + }) +}) diff --git a/tests/unit/backend/models/submission.server.model.spec.ts b/tests/unit/backend/models/submission.server.model.spec.ts index 9950990e58..0de2776b9a 100644 --- a/tests/unit/backend/models/submission.server.model.spec.ts +++ b/tests/unit/backend/models/submission.server.model.spec.ts @@ -1,73 +1,155 @@ import { ObjectID } from 'bson' +import { times } from 'lodash' import mongoose from 'mongoose' import getSubmissionModel from 'src/app/models/submission.server.model' -import { AuthType, SubmissionType } from '../../../../src/types' +import { + AuthType, + ISubmissionSchema, + SubmissionType, +} from '../../../../src/types' import dbHandler from '../helpers/jest-db' const Submission = getSubmissionModel(mongoose) // TODO: Add more tests for the rest of the submission schema. -describe('Submission Schema', () => { +describe('Submission Model', () => { beforeAll(async () => await dbHandler.connect()) afterEach(async () => await dbHandler.clearDatabase()) afterAll(async () => await dbHandler.closeDatabase()) const MOCK_ENCRYPTED_CONTENT = 'abcdefg encryptedContent' - describe('methods.getWebhookView', () => { - it('should return non-null view with encryptedSubmission type (without verified content)', async () => { - // Arrange - const formId = new ObjectID() - - const submission = await Submission.create({ - submissionType: SubmissionType.Encrypt, - form: formId, - encryptedContent: MOCK_ENCRYPTED_CONTENT, - version: 1, - authType: AuthType.NIL, - myInfoFields: [], - webhookResponses: [], + describe('Statics', () => { + describe('findFormsWithSubsAbove', () => { + it('should return ids and counts of forms with more than given minimum submissions', async () => { + // Arrange + const formCounts = [4, 2, 4] + const formIdsAndCounts = times(formCounts.length, (it) => ({ + _id: mongoose.Types.ObjectId(), + count: formCounts[it], + })) + const submissionPromises: Promise[] = [] + formIdsAndCounts.forEach(({ count, _id: formId }) => { + times(count, () => + submissionPromises.push( + Submission.create({ + form: formId, + myInfoFields: [], + submissionType: SubmissionType.Email, + responseHash: 'hash', + responseSalt: 'salt', + }), + ), + ) + }) + await Promise.all(submissionPromises) + const minSubCount = 3 + + // Act + const actualResult = await Submission.findFormsWithSubsAbove( + minSubCount, + ) + + // Assert + const expectedResult = formIdsAndCounts.filter( + ({ count }) => count > minSubCount, + ) + expect(actualResult).toEqual(expect.arrayContaining(expectedResult)) }) - // Act - const actualWebhookView = submission.getWebhookView() + it('should return an empty array if no forms have submission counts higher than given count', async () => { + // Arrange + const formCounts = [1, 1, 2] + const formIdsAndCounts = times(formCounts.length, (it) => ({ + _id: mongoose.Types.ObjectId(), + count: formCounts[it], + })) + const submissionPromises: Promise[] = [] + formIdsAndCounts.forEach(({ count, _id: formId }) => { + times(count, () => + submissionPromises.push( + Submission.create({ + form: formId, + myInfoFields: [], + submissionType: SubmissionType.Email, + responseHash: 'hash', + responseSalt: 'salt', + }), + ), + ) + }) + await Promise.all(submissionPromises) + // Tests for greater than, should not return even if equal to some + // submission count. + const minSubCount = 2 - // Assert - expect(actualWebhookView).toEqual({ - data: { - formId: expect.any(String), - submissionId: expect.any(String), - created: expect.any(Date), - encryptedContent: MOCK_ENCRYPTED_CONTENT, - verifiedContent: undefined, - version: 1, - }, + // Act + const actualResult = await Submission.findFormsWithSubsAbove( + minSubCount, + ) + + // Assert + expect(actualResult).toEqual([]) }) }) + }) - it('should return null view with non-encryptSubmission type', async () => { - // Arrange - const formId = new ObjectID() - const submission = await Submission.create({ - submissionType: SubmissionType.Email, - form: formId, - encryptedContent: MOCK_ENCRYPTED_CONTENT, - version: 1, - authType: AuthType.NIL, - myInfoFields: [], - recipientEmails: [], - responseHash: 'hash', - responseSalt: 'salt', - hasBounced: false, + describe('Methods', () => { + describe('getWebhookView', () => { + it('should return non-null view with encryptedSubmission type (without verified content)', async () => { + // Arrange + const formId = new ObjectID() + + const submission = await Submission.create({ + submissionType: SubmissionType.Encrypt, + form: formId, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 1, + authType: AuthType.NIL, + myInfoFields: [], + webhookResponses: [], + }) + + // Act + const actualWebhookView = submission.getWebhookView() + + // Assert + expect(actualWebhookView).toEqual({ + data: { + formId: expect.any(String), + submissionId: expect.any(String), + created: expect.any(Date), + encryptedContent: MOCK_ENCRYPTED_CONTENT, + verifiedContent: undefined, + version: 1, + }, + }) }) - // Act - const actualWebhookView = submission.getWebhookView() + it('should return null view with non-encryptSubmission type', async () => { + // Arrange + const formId = new ObjectID() + const submission = await Submission.create({ + submissionType: SubmissionType.Email, + form: formId, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 1, + authType: AuthType.NIL, + myInfoFields: [], + recipientEmails: [], + responseHash: 'hash', + responseSalt: 'salt', + hasBounced: false, + }) + + // Act + const actualWebhookView = submission.getWebhookView() - // Assert - expect(actualWebhookView).toBeNull() + // Assert + expect(actualWebhookView).toBeNull() + }) }) }) })