Skip to content

Commit

Permalink
Script to generate lots of data for perf tests
Browse files Browse the repository at this point in the history
  • Loading branch information
PurkkaKoodari committed Feb 23, 2024
1 parent 1170998 commit 3a859e7
Show file tree
Hide file tree
Showing 11 changed files with 343 additions and 24 deletions.
22 changes: 21 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ module.exports = {
namedComponents: ["function-declaration", "arrow-function"],
unnamedComponents: "arrow-function",
}],
// Allow dev deps in test files.
"import/no-extraneous-dependencies": ["error", {
devDependencies: [
"**/test/**",
"**/vite.config.ts",
"**/vitest.config.ts",
"**/.eslintrc.js"
],
}],
// Sort imports: React first, then npm packages, then local files, then CSS.
"simple-import-sort/imports": [
"error",
Expand All @@ -86,6 +95,17 @@ module.exports = {
["css$"]
]
}
]
],
// Prevent imports from "src/...". VS Code adds these automatically, but they
// break when compiled.
"no-restricted-imports": [
"error",
{
"patterns": [{
group: ["src/*"],
message: "This import will break when compiled by tsc. Use a relative path instead, or \"../src/\" in test files."
}],
},
],
}
};
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ coverage/
.idea/
.vscode/

.env
.env*
!.env*.example

data/

Expand Down
8 changes: 5 additions & 3 deletions packages/ilmomasiina-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
"start:prod": "bnr start:prod",
"stop:prod": "pm2 stop 0",
"monit:prod": "pm2 monit",
"build": "tsc --build",
"build": "tsc --build tsconfig.build.json",
"clean": "rimraf dist",
"typecheck": "tsc --build"
"typecheck": "tsc --build tsconfig.build.json",
"filldb": "ts-node -r tsconfig-paths/register --project tsconfig.json test/fillDatabase.ts"
},
"betterScripts": {
"start:dev": {
Expand Down Expand Up @@ -47,7 +48,7 @@
"bcrypt": "^5.1.0",
"better-npm-run": "^0.1.1",
"debug": "^4.3.4",
"dotenv": "^16.0.3",
"dotenv-flow": "^4.1.0",
"email-templates": "^8.1.0",
"fast-jwt": "^1.7.0",
"fastify": "^4.3.0",
Expand All @@ -67,6 +68,7 @@
"umzug": "^3.1.1"
},
"devDependencies": {
"@faker-js/faker": "^8.3.1",
"@types/bcrypt": "^5.0.0",
"@types/compression": "^1.7.2",
"@types/debug": "^4.1.7",
Expand Down
8 changes: 5 additions & 3 deletions packages/ilmomasiina-backend/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import dotenv from 'dotenv';
import dotenvFlow from 'dotenv-flow';
import path from 'path';

import {
envBoolean, envEnum, envInteger, envString, frontendFilesPath,
} from './util/config';

// Load environment variables from .env file (from the root of repository)
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
// Load environment variables from .env files (from the root of repository)
dotenvFlow.config({ path: path.resolve(__dirname, '../../..') });

// Compatibility for older configs
if (!process.env.BASE_URL && process.env.EMAIL_BASE_URL) {
Expand Down Expand Up @@ -61,6 +61,8 @@ const config = {
dbPassword: envString('DB_PASSWORD', null),
/** Database name. */
dbDatabase: envString('DB_DATABASE', null),
/** Required to run tests, as they reset the test database for every test. */
allowTestsToResetDb: envBoolean('THIS_IS_A_TEST_DB_AND_CAN_BE_WIPED', false),

/** Salt for generating legacy edit tokens. Used only to keep tokens valid from a previous installation. */
oldEditTokenSalt: envString('EDIT_TOKEN_SALT', null),
Expand Down
31 changes: 31 additions & 0 deletions packages/ilmomasiina-backend/test/fillDatabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { faker } from '@faker-js/faker';

import config from '../src/config';
import setupDatabase from '../src/models';
import { testEvent, testSignups } from './testData';

// Allows filling up the database with test events for performance testing.

const NUM_EVENTS = 2000;
const NUM_SIGNUPS_PER_EVENT = { min: 10, max: 200 };

if (!config.allowTestsToResetDb) {
throw new Error(
'THIS_IS_A_TEST_DB_AND_CAN_BE_WIPED=1 must be set to run fillDatabase.ts.\n'
+ `Warning: This script will insert ${NUM_EVENTS} with random signups into `
+ `your ${config.dbDialect} DB '${config.dbDatabase}' on ${config.dbHost}.`,
);
}

/* eslint-disable no-await-in-loop */
async function main() {
await setupDatabase();

for (let i = 0; i < NUM_EVENTS; i++) {
process.stderr.write(`\rCreating test events: ${i + 1}/${NUM_EVENTS}...`);
const event = await testEvent();
await testSignups(event, { count: faker.number.int(NUM_SIGNUPS_PER_EVENT) });
}
}

main();
238 changes: 238 additions & 0 deletions packages/ilmomasiina-backend/test/testData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { faker } from '@faker-js/faker';
import { range } from 'lodash';
import moment from 'moment';
import { UniqueConstraintError } from 'sequelize';

import { QuestionType, QuotaID } from '@tietokilta/ilmomasiina-models';
import { EventAttributes, SignupAttributes } from '@tietokilta/ilmomasiina-models/dist/models';
import { Answer, AnswerCreationAttributes } from '../src/models/answer';
import { Event } from '../src/models/event';
import { Question, QuestionCreationAttributes } from '../src/models/question';
import { Quota } from '../src/models/quota';
import { Signup, SignupCreationAttributes } from '../src/models/signup';
import { User } from '../src/models/user';

export function testUser() {
return User.create({
email: faker.internet.email(),
password: faker.internet.password(),
});
}

type TestEventOptions = {
hasDate?: boolean;
inPast?: boolean;
hasSignup?: boolean;
signupState?: 'not-open' | 'open' | 'closed';
questionCount?: number;
quotaCount?: number;
signupCount?: number;
};

/**
* Creates and saves a randomized test event.
*
* @param options Options for the event generation.
* @param overrides Fields to set on the event right before saving.
* @returns The created event, with `questions` and `quotas` populated.
*/
export async function testEvent({
hasDate = true,
inPast = false,
hasSignup = true,
signupState = inPast ? 'closed' : 'open',
questionCount = faker.number.int({ min: 1, max: 5 }),
quotaCount = faker.number.int({ min: 1, max: 4 }),
}: TestEventOptions = {}, overrides: Partial<EventAttributes> = {}) {
const title = faker.lorem.words({ min: 1, max: 5 });
const event = new Event({
title,
slug: faker.helpers.slugify(title),
description: faker.lorem.paragraphs({ min: 1, max: 5 }),
price: faker.finance.amount({ symbol: '€' }),
location: faker.location.streetAddress(),
facebookUrl: faker.internet.url(),
webpageUrl: faker.internet.url(),
category: faker.lorem.words({ min: 1, max: 2 }),
draft: false,
verificationEmail: faker.lorem.paragraphs({ min: 1, max: 5 }),
});
if (hasDate) {
if (inPast) {
event.endDate = faker.date.recent({ refDate: moment().subtract(14, 'days').toDate() });
event.date = faker.date.recent({ refDate: event.endDate });
} else {
event.date = faker.date.soon();
event.endDate = faker.date.soon({ refDate: event.date });
}
}
if (hasSignup) {
if (inPast && signupState === 'closed') {
event.registrationEndDate = faker.date.recent({ refDate: moment().subtract(14, 'days').toDate() });
event.registrationStartDate = faker.date.recent({ refDate: event.registrationEndDate });
} else if (signupState === 'closed') {
event.registrationEndDate = faker.date.recent();
event.registrationStartDate = faker.date.recent({ refDate: event.registrationEndDate });
} else if (signupState === 'not-open') {
event.registrationStartDate = faker.date.soon();
event.registrationEndDate = faker.date.soon({ refDate: event.registrationStartDate });
} else {
event.registrationStartDate = faker.date.recent();
event.registrationEndDate = faker.date.soon();
}
}
event.set(overrides);
try {
await event.save();
} catch (err) {
if (err instanceof UniqueConstraintError) {
// Slug must be unique... this ought to be enough.
event.slug += faker.string.alphanumeric(8);
await event.save();
} else {
throw err;
}
}
event.questions = await Question.bulkCreate(range(questionCount).map((i) => {
const question: QuestionCreationAttributes = {
eventId: event.id,
order: i,
question: faker.lorem.words({ min: 1, max: 5 }),
type: faker.helpers.arrayElement(Object.values(QuestionType)),
required: faker.datatype.boolean(),
public: faker.datatype.boolean(),
};
if (question.type === QuestionType.SELECT || question.type === QuestionType.CHECKBOX) {
question.options = faker.helpers.multiple(
() => faker.lorem.words({ min: 1, max: 3 }),
{ count: { min: 1, max: 8 } },
);
}
return question;
}));
event.quotas = await Quota.bulkCreate(range(quotaCount).map((i) => ({
eventId: event.id,
order: i,
title: faker.lorem.words({ min: 1, max: 5 }),
size: faker.helpers.maybe(() => faker.number.int({ min: 1, max: 50 }), { probability: 0.9 }) ?? null,
})));
return event;
}

type TestSignupsOptions = {
count?: number;
quotaId?: QuotaID;
expired?: boolean;
confirmed?: boolean;
};

export async function testSignups(
event: Event,
{
count = faker.number.int({ min: 1, max: 40 }),
quotaId,
expired = false,
confirmed = expired ? false : undefined,
}: TestSignupsOptions = {},
overrides: Partial<SignupAttributes> = {},
) {
if (!event.quotas || !event.questions) {
throw new Error('testSignups() expects event.quotas and event.questions to be populated');
}
if (!event.quotas.length) {
throw new Error('testSignups() needs at least one existing quota');
}
const signups = await Signup.bulkCreate(range(count).map(() => {
const signup: SignupCreationAttributes = {
quotaId: quotaId ?? faker.helpers.arrayElement(event.quotas!).id,
};
if (expired) {
// Expired signup (never confirmed)
signup.createdAt = faker.date.recent({ refDate: moment().subtract(30, 'minutes').toDate() });
} else if (confirmed ?? faker.datatype.boolean({ probability: 0.8 })) {
// Confirmed signup
signup.confirmedAt = faker.date.recent();
signup.createdAt = faker.date.between({
from: moment(signup.confirmedAt).subtract(30, 'minutes').toDate(),
to: signup.confirmedAt,
});
if (event.nameQuestion) {
signup.firstName = faker.person.firstName();
signup.lastName = faker.person.lastName();
signup.namePublic = faker.datatype.boolean();
}
if (event.emailQuestion) {
signup.email = faker.internet.email({
firstName: signup.firstName ?? undefined,
lastName: signup.lastName ?? undefined,
});
}
} else {
// Unconfirmed signup
signup.createdAt = faker.date.between({
from: moment().subtract(30, 'minutes').toDate(),
to: new Date(),
});
}
return {
...signup,
...overrides,
};
}));
await Answer.bulkCreate(signups.flatMap((signup) => {
if (!signup.confirmedAt) return [];
return event.questions!.map((question) => {
const answer: AnswerCreationAttributes = {
questionId: question.id,
signupId: signup.id,
answer: '',
};
// Generate answer value based on question type and other constraints
if (question.type === QuestionType.TEXT) {
answer.answer = faker.helpers.maybe(
() => faker.lorem.words({ min: 1, max: 3 }),
{ probability: question.required ? 1 : 0.5 },
) ?? '';
} else if (question.type === QuestionType.TEXT_AREA) {
answer.answer = faker.helpers.maybe(
() => faker.lorem.sentences({ min: 1, max: 2 }),
{ probability: question.required ? 1 : 0.5 },
) ?? '';
} else if (question.type === QuestionType.NUMBER) {
answer.answer = faker.helpers.maybe(
() => faker.number.int().toString(),
{ probability: question.required ? 1 : 0.5 },
) ?? '';
} else if (question.type === QuestionType.SELECT) {
answer.answer = faker.helpers.maybe(
() => faker.helpers.arrayElement(question.options!),
{ probability: question.required ? 1 : 0.5 },
) ?? '';
} else if (question.type === QuestionType.CHECKBOX) {
answer.answer = faker.helpers.arrayElements(
question.options!,
{ min: question.required ? 1 : 0, max: Infinity },
);
} else {
question.type satisfies never;
}
return answer;
});
}));
return signups;
}

export async function fetchSignups(event: Event) {
if (!event.quotas) {
throw new Error('fetchSignups() expects event.quotas and event.questions to be populated');
}
if (!event.quotas.length) {
throw new Error('fetchSignups() needs at least one existing quota');
}
await Promise.all(event.quotas.map(async (quota) => {
// eslint-disable-next-line no-param-reassign
quota.signups = await quota.getSignups({
include: [Answer],
});
}));
}
11 changes: 11 additions & 0 deletions packages/ilmomasiina-backend/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"module": "CommonJS"
},
"include": ["src/**/*"],
"references": [
{ "path": "../ilmomasiina-models" }
]
}
Loading

0 comments on commit 3a859e7

Please sign in to comment.