diff --git a/.eslintrc.js b/.eslintrc.js index e50a451a..bb6bb91c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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", @@ -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." + }], + }, + ], } }; diff --git a/.gitignore b/.gitignore index 7fa9de09..4f5c3753 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,8 @@ coverage/ .idea/ .vscode/ -.env +.env* +!.env*.example data/ diff --git a/packages/ilmomasiina-backend/package.json b/packages/ilmomasiina-backend/package.json index 1ef74fe8..7efe0cf2 100644 --- a/packages/ilmomasiina-backend/package.json +++ b/packages/ilmomasiina-backend/package.json @@ -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": { @@ -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", @@ -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", diff --git a/packages/ilmomasiina-backend/src/config.ts b/packages/ilmomasiina-backend/src/config.ts index ef8a172a..ca50bcee 100644 --- a/packages/ilmomasiina-backend/src/config.ts +++ b/packages/ilmomasiina-backend/src/config.ts @@ -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) { @@ -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), diff --git a/packages/ilmomasiina-backend/test/fillDatabase.ts b/packages/ilmomasiina-backend/test/fillDatabase.ts new file mode 100644 index 00000000..4b5a9064 --- /dev/null +++ b/packages/ilmomasiina-backend/test/fillDatabase.ts @@ -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(); diff --git a/packages/ilmomasiina-backend/test/testData.ts b/packages/ilmomasiina-backend/test/testData.ts new file mode 100644 index 00000000..4f76d156 --- /dev/null +++ b/packages/ilmomasiina-backend/test/testData.ts @@ -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 = {}) { + 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 = {}, +) { + 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], + }); + })); +} diff --git a/packages/ilmomasiina-backend/tsconfig.build.json b/packages/ilmomasiina-backend/tsconfig.build.json new file mode 100644 index 00000000..00752705 --- /dev/null +++ b/packages/ilmomasiina-backend/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "module": "CommonJS" + }, + "include": ["src/**/*"], + "references": [ + { "path": "../ilmomasiina-models" } + ] +} diff --git a/packages/ilmomasiina-backend/tsconfig.json b/packages/ilmomasiina-backend/tsconfig.json index 3fa4a65a..becf99cd 100644 --- a/packages/ilmomasiina-backend/tsconfig.json +++ b/packages/ilmomasiina-backend/tsconfig.json @@ -19,7 +19,7 @@ "resolveJsonModule": true, "isolatedModules": true, "incremental": true, - "rootDir": "src", + "rootDirs": ["src", "test"], "outDir": "dist", "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", "baseUrl": ".", @@ -33,7 +33,8 @@ "dist" ], "include": [ - "src/**/*" + "src/**/*", + "test/**/*" ], "references": [ { "path": "../ilmomasiina-models" } diff --git a/packages/ilmomasiina-frontend/package.json b/packages/ilmomasiina-frontend/package.json index aea5abef..c5ef147b 100644 --- a/packages/ilmomasiina-frontend/package.json +++ b/packages/ilmomasiina-frontend/package.json @@ -30,7 +30,7 @@ "connected-react-router": "^6.9.2", "csv-stringify": "^6.4.2", "date-fns": "^2.28.0", - "dotenv": "^16.0.3", + "dotenv-flow": "^4.1.0", "final-form": "^4.20.10", "final-form-arrays": "^3.1.0", "history": "^4.10.1", diff --git a/packages/ilmomasiina-frontend/vite.config.ts b/packages/ilmomasiina-frontend/vite.config.ts index 3749cba2..337f6bc8 100644 --- a/packages/ilmomasiina-frontend/vite.config.ts +++ b/packages/ilmomasiina-frontend/vite.config.ts @@ -1,5 +1,5 @@ import react from '@vitejs/plugin-react'; -import dotenv from 'dotenv'; +import dotenvFlow from 'dotenv-flow'; import path from 'path'; import { defineConfig } from 'vite'; import checker from 'vite-plugin-checker'; @@ -9,7 +9,8 @@ import momentPlugin from './src/rollupMomentPlugin'; /* eslint-disable no-console */ -dotenv.config({ path: path.resolve(__dirname, '../../.env') }); +// Load environment variables from .env files (from the root of repository) +dotenvFlow.config({ path: path.resolve(__dirname, '../..') }); // Default to 127.0.0.1:3001 for the backend. // Use the dev-only variable DEV_BACKEND_PORT for this, keeping PORT always for the user-facing port. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2d6013e..e3e0b817 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,7 @@ importers: packages/ilmomasiina-backend: specifiers: + '@faker-js/faker': ^8.3.1 '@fastify/compress': ^6.1.0 '@fastify/cors': ^8.2.0 '@fastify/sensible': ^5.1.0 @@ -62,7 +63,7 @@ importers: 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 @@ -94,12 +95,12 @@ importers: '@sinclair/typebox': 0.24.22 '@tietokilta/ilmomasiina-models': link:../ilmomasiina-models ajv: 8.12.0 - ajv-formats: 2.1.1_ajv@8.12.0 + ajv-formats: 2.1.1 base32-encode: 1.2.0 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 @@ -118,6 +119,7 @@ importers: sequelize: 6.21.0_ek64ldxalhadmdq3ipl6ljgiqu umzug: 3.1.1 devDependencies: + '@faker-js/faker': 8.4.1 '@types/bcrypt': 5.0.0 '@types/compression': 1.7.2 '@types/debug': 4.1.7 @@ -198,7 +200,7 @@ importers: connected-react-router: ^6.9.2 csv-stringify: ^6.4.2 date-fns: ^2.28.0 - dotenv: ^16.0.3 + dotenv-flow: ^4.1.0 final-form: ^4.20.10 final-form-arrays: ^3.1.0 history: ^4.10.1 @@ -250,7 +252,7 @@ importers: connected-react-router: 6.9.2_4xep6jux73jwrxkjwaaein5o3u csv-stringify: 6.4.2 date-fns: 2.28.0 - dotenv: 16.0.3 + dotenv-flow: 4.1.0 final-form: 4.20.10 final-form-arrays: 3.1.0_final-form@4.20.10 history: 4.10.1 @@ -814,6 +816,11 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: false + /@faker-js/faker/8.4.1: + resolution: {integrity: sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'} + dev: true + /@fastify/accept-negotiator/1.0.0: resolution: {integrity: sha512-4R/N2KfYeld7A5LGkai+iUFMahXcxxYbDp+XS2B1yuL3cdmZLJ9TlCnNzT3q5xFTqsYm0GPpinLUwfSwjcVjyA==} engines: {node: '>=14'} @@ -823,7 +830,7 @@ packages: resolution: {integrity: sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==} dependencies: ajv: 8.12.0 - ajv-formats: 2.1.1_ajv@8.12.0 + ajv-formats: 2.1.1 fast-uri: 2.1.0 dev: false @@ -1754,10 +1761,8 @@ packages: - supports-color dev: false - /ajv-formats/2.1.1_ajv@8.12.0: + /ajv-formats/2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 peerDependenciesMeta: ajv: optional: true @@ -2342,7 +2347,7 @@ packages: lodash: ^4.17.20 marko: ^3.14.4 mote: ^0.2.0 - mustache: ^3.0.0 + mustache: ^4.0.1 nunjucks: ^3.2.2 plates: ~0.4.11 pug: ^3.0.0 @@ -2935,6 +2940,13 @@ packages: domelementtype: 2.3.0 domhandler: 5.0.3 + /dotenv-flow/4.1.0: + resolution: {integrity: sha512-0cwP9jpQBQfyHwvE0cRhraZMkdV45TQedA8AAUZMsFzvmLcQyc1HPv+oX0OOYwLFjIlvgVepQ+WuQHbqDaHJZg==} + engines: {node: '>= 12.0.0'} + dependencies: + dotenv: 16.0.3 + dev: false + /dotenv/16.0.3: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} @@ -3635,7 +3647,7 @@ packages: dependencies: '@fastify/deepmerge': 1.1.0 ajv: 8.12.0 - ajv-formats: 2.1.1_ajv@8.12.0 + ajv-formats: 2.1.1 fast-uri: 2.1.0 rfdc: 1.3.0 dev: false